immer.js + typescript - How to write a typed query function?

Viewed 44

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.

1 Answers

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
});

Related