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.