1

I've been trying out Immer.js with Typescript, but I keep running into a similar issue. Is there a pattern I can use to avoid some ugly code?

Consider the following Typescript code:

import produce, {castDraft} from "immer";

interface Item {
  readonly id: number;
  readonly value?: string;
}

interface State {
  readonly items: Item[];
}

function findItemById(state: State, id: number): Item {
  return state.items.find(x => x.id === id)!;
}

const state: State = {
  items: [{ id: 1 }, { id: 2 }, { id: 3 }]
};

// This does not work.
produce(state, draft => {
  const item = findItemById(draft, 2);
  // Cannot assign to "value" because it is a read-only property.
  item.value = "Something";
});

// This works, but it's not ideal
produce(state, draft => {
  const item = castDraft(findItemById(draft, 2));
  item.value = "Something";
});

We're following the Immer.js recommendations of setting our State as readonly. We have a 'query function', which is used to pull a chunk out of the state. In this example, that's findItemById(). We quickly run into a problem, where whenever we grab the chunk of state from our draft instance in produce(), it loses the mutability wrapper and we cannot assign to it. We can use the function castDraft() to work around this issue, but I find that less than ideal - we will be using these kind of query functions a lot in our producers, so needing to cast all the time is problematic and error prone (if you accidentally cast from the external state and not the draft, you're in trouble!)

What we'd like to do is to define the type of the function findItemById() in such a way that it returns a writeable Item when the input State is writeable, otherwise returns an immutable item. I tried something like the following:

function findItemById<T extends Draft<State> | State>(
  state: T,
  id: number
): T extends Draft<State> ? Draft<Item> : Item {
  return state.items.find((x) => x.id === id)!;
}

But that didn't seem to work - the result was always writeable.

Daniel
  • 1,125
  • 2
  • 9
  • 21

1 Answers1

0

This is because second draft argument in produce function is is a State wrapped into WritableDraft.

Let's take a look what is WritableDraft.

export declare type WritableDraft<T> = {
    -readonly [K in keyof T]: Draft<T[K]>;
};

As you might have noticed, this type goes through first level of keys and makes them mutable. In other words it is shallow mutable type. Ut means that all lested properties could be still immutable.

Furthermore, your function findItemById expects State argument, whereas it is better to use generic here, since you call it with draft argument which is WritableDraft<State>.

So, it is better to declare it as:

function findItemById<S extends State>(state: S, id: number) {
  return state.items.find((x) => x.id === id)!;
}

However, it is still does not work. Lets take a look at castDraft (source code):

export function castDraft<T>(value: T): Draft<T> {
    return value as any
}

and Draft type:

export declare type Draft<T> = T extends PrimitiveType
  ? T
  : T extends AtomicObject
  ? T
  : T extends IfAvailable<ReadonlyMap<infer K, infer V>>
  ? Map<Draft<K>, Draft<V>>
  : T extends IfAvailable<ReadonlySet<infer V>>
  ? Set<Draft<V>>
  : T extends WeakReferences
  ? T
  : T extends object
  ? WritableDraft<T>
  : T

castDraft is a shell function, it just call type assertion under the hood. Draft type is a recursive type, it goes through all nested types, this is why castDraft works.

Maybe it worth operating on items instead of state:


interface Item {
  readonly id: number;
  readonly value?: string;
}

interface State {
  readonly items: Item[];
}

function findItemById<Items extends Item[]>(
  items: Items,
  id: number
): Draft<Item> {
  return items.find((x) => x.id === id)!;
}

const state: State = {
  items: [{ id: 1 }, { id: 2 }, { id: 3 }],
};

// This does not work.
produce(state.items, (draft) => {
  const item = findItemById(draft, 2);
  item.value = "Something"; // works
});

  • Thanks for your response! Unfortunately, this doesn't really help - always returning `Draft` means that when the query is used on a regular immutable state instance, the resulting item is considered mutable by the compiler, which is incorrect. I also don't particularly see why operating on `items` instead of `state` makes a difference? – Daniel May 21 '22 at 12:37
  • @Daniel I don't have an exp working with `immer.js`. Just wanted to provide you with info. I think using `castDraft` is ok. THis is a runtime overhead. However, I don't think it will affect your perf. See [this](https://stackoverflow.com/questions/66866683/do-we-have-any-guarantees-about-arrow-functions-optimization-in-js) answer – captain-yossarian from Ukraine May 21 '22 at 13:10
  • It's not the performance overhead that bothers me, it's the requirement for adding a cast (which is very error prone) – Daniel May 22 '22 at 01:58