From 2c2f30e1c7bda5a1902acb4548678434e18cae5d Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Fri, 19 Mar 2021 21:13:55 +0000 Subject: [PATCH] feature: Improved typescript types The TypeScript typings of Immer have been rewritten, and support better inference now and are more accurate. The best practices are documented here: https://immerjs.github.io/immer/typescript. Please visit that page first when running into any compile errors after updating. The following changes have been made: Immer can now infer the type of the `draft` for curried producers, if they are directly passed to another function / context from which the type can be inferred. So lines like these will now be strongly typed: `setTodo(produce(draft => { draft.done = true }))`, where `setTodo` is a React state updater (for example). Fixes #720. Immer will now better respect the original types passed to a producer, and generally not wrap any returned types with `Immutable` automatically. BREAKING CHANGE: It is no longer allowed to return `nothing` from a recipe if the target state doesn't accept `undefined`. BREAKING CHANGE: It is no longer allowed to return arbitrary things from a recipe. Recipes should either return nothing, or something that is assignable to the original state type. This will catch mistakes with accidental returns earlier. --- __tests__/produce.ts | 382 ++++++++++++++++++++++++++++++------ __tests__/redux.ts | 283 +++++++++++++++++++------- package.json | 4 +- src/types/types-external.ts | 201 ++++++++++++------- website/docs/return.mdx | 5 +- website/docs/typescript.mdx | 101 ++++++++-- yarn.lock | 16 +- 7 files changed, 761 insertions(+), 231 deletions(-) diff --git a/__tests__/produce.ts b/__tests__/produce.ts index c0fb74ed..efc43703 100644 --- a/__tests__/produce.ts +++ b/__tests__/produce.ts @@ -48,7 +48,7 @@ const expectedState: State = { } it("can update readonly state via standard api", () => { - const newState = produce(state, draft => { + const newState = produce(state, draft => { draft.num++ draft.foo = "bar" draft.bar = "foo" @@ -59,37 +59,48 @@ it("can update readonly state via standard api", () => { draft.arr2[0].value = "foo" draft.arr2.push({value: "asf"}) }) - assert(newState, state) + assert(newState, _ as State) }) // NOTE: only when the function type is inferred it("can infer state type from default state", () => { type State = {readonly a: number} - type Recipe = (state?: S | undefined) => S + type Recipe = (state?: State | undefined) => State let foo = produce((_: any) => {}, _ as State) assert(foo, _ as Recipe) }) it("can infer state type from recipe function", () => { - type State = {readonly a: string} | {readonly b: string} - type Recipe = (state: S) => S - - let foo = produce((_: Draft) => {}) + type A = {readonly a: string} + type B = {readonly b: string} + type State = A | B + type Recipe = (state: State) => State + + let foo = produce((draft: State) => { + assert(draft, _ as State) + if (Math.random() > 0.5) return {a: "test"} + else return {b: "boe"} + }) + const x = foo({a: ""}) + const y = foo({b: ""}) assert(foo, _ as Recipe) }) it("can infer state type from recipe function with arguments", () => { type State = {readonly a: string} | {readonly b: string} - type Recipe = (state: S, x: number) => S + type Recipe = (state: State, x: number) => State - let foo = produce((draft: Draft, x: number) => {}) + let foo = produce((draft, x) => { + assert(draft, _ as Draft) + assert(x, _ as number) + }) assert(foo, _ as Recipe) }) it("can infer state type from recipe function with arguments and initial state", () => { type State = {readonly a: string} | {readonly b: string} - type Recipe = (state: S | undefined, x: number) => S + type Recipe = (state: State | undefined, x: number) => State let foo = produce((draft: Draft, x: number) => {}, _ as State) assert(foo, _ as Recipe) @@ -155,7 +166,7 @@ describe("curried producer", () => { // No initial state: { - type Recipe = (state: S, a: number, b: number) => S + type Recipe = (state: State, a: number, b: number) => State let foo = produce((s: State, a: number, b: number) => {}) assert(foo, _ as Recipe) foo(_ as State, 1, 2) @@ -163,7 +174,7 @@ describe("curried producer", () => { // Using argument parameters: { - type Recipe = (state: S, ...rest: number[]) => S + type Recipe = (state: Immutable, ...rest: number[]) => Draft let woo = produce((state: Draft, ...args: number[]) => {}) assert(woo, _ as Recipe) woo(_ as State, 1, 2) @@ -171,10 +182,7 @@ describe("curried producer", () => { // With initial state: { - type Recipe = ( - state?: S | undefined, - ...rest: number[] - ) => S + type Recipe = (state?: State | undefined, ...rest: number[]) => State let bar = produce((state: Draft, ...args: number[]) => {}, _ as State) assert(bar, _ as Recipe) @@ -185,11 +193,11 @@ describe("curried producer", () => { // When args is a tuple: { - type Recipe = ( - state: S | undefined, + type Recipe = ( + state: State | undefined, first: string, ...rest: number[] - ) => S + ) => State let tup = produce( (state: Draft, ...args: [string, ...number[]]) => {}, _ as State @@ -204,7 +212,7 @@ describe("curried producer", () => { // No initial state: { let foo = produce((state: string[]) => {}) - assert(foo, _ as (state: S) => S) + assert(foo, _ as (state: readonly string[]) => string[]) foo([] as ReadonlyArray) } @@ -213,7 +221,7 @@ describe("curried producer", () => { let bar = produce(() => {}, [] as ReadonlyArray) assert( bar, - _ as (state?: S | undefined) => S + _ as (state?: readonly string[] | undefined) => readonly string[] ) bar([] as ReadonlyArray) bar(undefined) @@ -223,17 +231,17 @@ describe("curried producer", () => { }) it("works with return type of: number", () => { - let base = _ as {a: number} - let result = produce(base, () => 1) - assert(result, _ as number) -}) - -it("works with return type of: number | undefined", () => { let base = {a: 0} as {a: number} - let result = produce(base, draft => { - return draft.a < 0 ? 0 : undefined - }) - assert(result, _ as {a: number} | number) + { + if (Math.random() === 100) { + // @ts-expect-error, this return accidentally a number, this is probably a dev error! + let result = produce(base, draft => draft.a++) + } + } + { + let result = produce(base, draft => void draft.a++) + assert(result, _ as {a: number}) + } }) it("can return an object type that is identical to the base type", () => { @@ -241,16 +249,15 @@ it("can return an object type that is identical to the base type", () => { let result = produce(base, draft => { return draft.a < 0 ? {a: 0} : undefined }) - // TODO: Can we resolve the weird union of identical object types? - assert(result, _ as {a: number} | {a: number}) + assert(result, _ as {a: number}) }) -it("can return an object type that is _not_ assignable to the base type", () => { +it("can NOT return an object type that is _not_ assignable to the base type", () => { let base = {a: 0} as {a: number} + // @ts-expect-error let result = produce(base, draft => { return draft.a < 0 ? {a: true} : undefined }) - assert(result, _ as {a: number} | {a: boolean}) }) it("does not enforce immutability at the type level", () => { @@ -261,25 +268,25 @@ it("does not enforce immutability at the type level", () => { }) it("can produce an undefined value", () => { - let base = {a: 0} as {readonly a: number} + type State = {readonly a: number} | undefined + const base = {a: 0} as State // Return only nothing. let result = produce(base, _ => nothing) - assert(result, undefined) + assert(result, _ as State) // Return maybe nothing. let result2 = produce(base, draft => { - if (draft.a > 0) return nothing + if (draft?.a ?? 0 > 0) return nothing }) - assert(result2, _ as typeof base | undefined) + assert(result2, _ as State) }) it("can return the draft itself", () => { let base = _ as {readonly a: number} let result = produce(base, draft => draft) - // Currently, the `readonly` modifier is lost. - assert(result, _ as {a: number}) + assert(result, _ as {readonly a: number}) }) it("can return a promise", () => { @@ -288,15 +295,15 @@ it("can return a promise", () => { // Return a promise only. let res1 = produce(base, draft => { - return Promise.resolve(draft.a > 0 ? null : undefined) + return Promise.resolve(draft) }) - assert(res1, _ as Promise) + assert(res1, _ as Promise) // Return a promise or undefined. let res2 = produce(base, draft => { - if (draft.a > 0) return Promise.resolve() + return Promise.resolve() }) - assert(res2, _ as Base | Promise) + assert(res2, _ as Promise) }) it("works with `void` hack", () => { @@ -331,13 +338,14 @@ it("can work with non-readonly base types", () => { } type State = typeof state - const newState: State = produce(state, draft => { + const newState = produce(state, draft => { draft.price += 5 draft.todos.push({ title: "hi", done: true }) }) + assert(newState, _ as State) const reducer = (draft: State) => { draft.price += 5 @@ -352,9 +360,9 @@ it("can work with non-readonly base types", () => { assert(newState4, _ as State) // no argument case, in that case, immutable version recipe first arg will be inferred const newState5 = produce(reducer, state)() - assert(newState5, _ as Immutable) - // we can force the return type of the reducer by passing the generic argument - const newState3 = produce(reducer, state)() + assert(newState5, _ as State) + // we can force the return type of the reducer by casting the initial state + const newState3 = produce(reducer, state as State)() assert(newState3, _ as State) }) @@ -377,13 +385,15 @@ it("can work with readonly base types", () => { ] } - const newState: State = produce(state, draft => { + const newState = produce(state, draft => { draft.price + 5 draft.todos.push({ title: "hi", done: true }) }) + assert(newState, _ as State) + assert(newState, _ as Immutable) // cause that is the same! const reducer = (draft: Draft) => { draft.price += 5 @@ -400,9 +410,9 @@ it("can work with readonly base types", () => { assert(newState4, _ as State) // no argument case, in that case, immutable version recipe first arg will be inferred const newState5 = produce(reducer, state)() - assert(newState5, _ as Immutable) - // we can force the return type of the reducer by passing the generic argument - const newState3 = produce(reducer, state)() + assert(newState5, _ as State) + // we can force the return type of the reducer by casting initial argument + const newState3 = produce(reducer, state as State)() assert(newState3, _ as State) }) @@ -438,18 +448,18 @@ it("works with Map and Set", () => { it("works with readonly Map and Set", () => { type S = {readonly x: number} - const m = new Map([["a", {x: 1}]]) - const s = new Set([{x: 2}]) + const m: ReadonlyMap = new Map([["a", {x: 1}]]) + const s: ReadonlySet = new Set([{x: 2}]) const res1 = produce(m, (draft: Draft>) => { assert(draft, _ as Map) }) - assert(res1, _ as Map) + assert(res1, _ as ReadonlyMap) const res2 = produce(s, (draft: Draft>) => { assert(draft, _ as Set<{x: number}>) }) - assert(res2, _ as Set<{readonly x: number}>) + assert(res2, _ as ReadonlySet<{readonly x: number}>) }) it("works with ReadonlyMap and ReadonlySet", () => { @@ -494,3 +504,261 @@ it("#749 types Immer", () => { // @ts-expect-error expect(z.z).toBeUndefined() }) + +it("infers draft, #720", () => { + function nextNumberCalculator(fn: (base: number) => number) { + // noop + } + + const res2 = nextNumberCalculator( + produce(draft => { + // @ts-expect-error + let x: string = draft + return draft + 1 + }) + ) + + const res = nextNumberCalculator( + produce(draft => { + // @ts-expect-error + let x: string = draft + // return draft + 1; + return undefined + }) + ) +}) + +it("infers draft, #720 - 2", () => { + function useState( + initialState: S | (() => S) + ): [S, Dispatch>] { + return [initialState, function() {}] as any + } + type Dispatch = (value: A) => void + type SetStateAction = S | ((prevState: S) => S) + + const [n, setN] = useState({x: 3}) + + setN( + produce(draft => { + // @ts-expect-error + draft.y = 4 + draft.x = 5 + return draft + }) + ) + + setN( + produce(draft => { + // @ts-expect-error + draft.y = 4 + draft.x = 5 + // return draft + 1; + return undefined + }) + ) + + setN( + produce(draft => { + return {y: 3} as const + }) + ) +}) + +it("infers draft, #720 - 3", () => { + function useState( + initialState: S | (() => S) + ): [S, Dispatch>] { + return [initialState, function() {}] as any + } + type Dispatch = (value: A) => void + type SetStateAction = S | ((prevState: S) => S) + + const [n, setN] = useState({x: 3} as {readonly x: number}) + + setN( + produce(draft => { + // @ts-expect-error + draft.y = 4 + draft.x = 5 + return draft + }) + ) + + setN( + produce(draft => { + // @ts-expect-error + draft.y = 4 + draft.x = 5 + // return draft + 1; + return undefined + }) + ) + + setN( + produce(draft => { + return {y: 3} as const + }) + ) +}) + +it("infers curried", () => { + type Todo = {title: string} + { + const fn = produce((draft: Todo) => { + let x: string = draft.title + }) + + fn({title: "test"}) + // @ts-expect-error + fn(3) + } + { + const fn = produce((draft: Todo) => { + let x: string = draft.title + return draft + }) + + fn({title: "test"}) + // @ts-expect-error + fn(3) + } +}) + +it("infers async curried", async () => { + type Todo = {title: string} + { + const fn = produce(async (draft: Todo) => { + let x: string = draft.title + }) + + const res = await fn({title: "test"}) + // @ts-expect-error + fn(3) + assert(res, _ as Todo) + } + { + const fn = produce(async (draft: Todo) => { + let x: string = draft.title + return draft + }) + + const res = await fn({title: "test"}) + // @ts-expect-error + fn(3) + assert(res, _ as Todo) + } +}) + +{ + type State = {count: number} + type ROState = Immutable + const base: any = {count: 0} + { + // basic + const res = produce(base as State, draft => { + draft.count++ + }) + assert(res, _ as State) + } + { + // basic + const res = produce(base, draft => { + draft.count++ + }) + assert(res, _ as State) + } + { + // basic + const res = produce(base as ROState, draft => { + draft.count++ + }) + assert(res, _ as ROState) + } + { + // curried + const f = produce((state: State) => { + state.count++ + }) + assert(f, _ as (state: Immutable) => State) + } + { + // curried + const f = produce((state: Draft) => { + state.count++ + }) + assert(f, _ as (state: ROState) => State) + } + { + // curried + const f: (value: State) => State = produce(state => { + state.count++ + }) + } + { + // curried + const f: (value: ROState) => ROState = produce(state => { + state.count++ + }) + } + { + // curried initial + const f = produce(state => { + state.count++ + }, _ as State) + assert(f, _ as (state?: State) => State) + } + { + // curried initial + const f = produce(state => { + state.count++ + }, _ as ROState) + assert(f, _ as (state?: ROState) => ROState) + } + { + // curried + const f: (value: State) => State = produce(state => { + state.count++ + }, base as ROState) + } + { + // curried + const f: (value: ROState) => ROState = produce(state => { + state.count++ + }, base as ROState) + } + { + // nothing allowed + const res = produce(base as State | undefined, draft => { + return nothing + }) + assert(res, _ as State | undefined) + } + { + // nothing not allowed + // @ts-expect-error + produce(base as State, draft => { + return nothing + }) + } + { + const f = produce((draft: State) => {}) + const n = f(base as State) + assert(n, _ as State) + } + { + const f = produce((draft: Draft) => { + draft.count++ + }) + const n = f(base as ROState) + assert(n, _ as State) + } + { + // explictly use generic + const f = produce(draft => { + draft.count++ + }) + const n = f(base as ROState) + assert(n, _ as ROState) // yay! + } +} diff --git a/__tests__/redux.ts b/__tests__/redux.ts index 6de78fa3..7e598f5a 100644 --- a/__tests__/redux.ts +++ b/__tests__/redux.ts @@ -1,101 +1,234 @@ import {assert, _} from "spec.ts" -import produce, { - produce as produce2, - applyPatches, - Patch, - nothing, - Draft, - Immutable, - enableES5 -} from "../src/immer" +import produce, {Draft, enableES5, Immutable} from "../src/immer" import * as redux from "redux" enableES5() -interface State { - counter: number -} +// Mutable Redux +{ + interface State { + counter: number + } -interface Action { - type: string - payload: number -} + interface Action { + type: string + payload: number + } + + const initialState: State = { + counter: 0 + } + + /// =============== Actions + + const reduceCounterProducer = (state: State = initialState, action: Action) => + produce(state, draftState => { + switch (action.type) { + case "ADD_TO_COUNTER": + draftState.counter += action.payload + break + case "SUB_FROM_COUNTER": + draftState.counter -= action.payload + break + } + }) + + const reduceCounterCurriedProducer = produce( + (draftState: State, action: Action) => { + switch (action.type) { + case "ADD_TO_COUNTER": + draftState.counter += action.payload + break + case "SUB_FROM_COUNTER": + draftState.counter -= action.payload + break + } + }, + initialState + ) + + /// =============== Reducers + + const reduce = redux.combineReducers({ + counterReducer: reduceCounterProducer + }) -const initialState: State = { - counter: 0 + const curredReduce = redux.combineReducers({ + counterReducer: reduceCounterCurriedProducer + }) + + // reducing the current state to get the next state! + // console.log(reduce(initialState, addToCounter(12)); + + // ================ store + + const store = redux.createStore(reduce) + const curriedStore = redux.createStore(curredReduce) + + it("#470 works with Redux combine reducers", () => { + assert( + store.getState().counterReducer, + _ as { + counter: number + } + ) + assert( + curriedStore.getState().counterReducer, + _ as { + counter: number + } + ) + }) } -/// =============== Actions +// Readonly Redux +{ + { + interface State { + readonly counter: number + } + + interface Action { + readonly type: string + readonly payload: number + } -function addToCounter(addNumber: number) { - return { - type: "ADD_TO_COUNTER", - payload: addNumber + const initialState: State = { + counter: 0 + } + + /// =============== Actions + + const reduceCounterProducer = ( + state: State = initialState, + action: Action + ) => + produce(state, draftState => { + switch (action.type) { + case "ADD_TO_COUNTER": + draftState.counter += action.payload + break + case "SUB_FROM_COUNTER": + draftState.counter -= action.payload + break + } + }) + + const reduceCounterCurriedProducer = produce( + (draftState: Draft, action: Action) => { + switch (action.type) { + case "ADD_TO_COUNTER": + draftState.counter += action.payload + break + case "SUB_FROM_COUNTER": + draftState.counter -= action.payload + break + } + }, + initialState + ) + + /// =============== Reducers + + const reduce = redux.combineReducers({ + counterReducer: reduceCounterProducer + }) + + const curredReduce = redux.combineReducers({ + counterReducer: reduceCounterCurriedProducer + }) + + // reducing the current state to get the next state! + // console.log(reduce(initialState, addToCounter(12)); + + // ================ store + + const store = redux.createStore(reduce) + const curriedStore = redux.createStore(curredReduce) + + it("#470 works with Redux combine readonly reducers", () => { + assert( + store.getState().counterReducer, + _ as { + readonly counter: number + } + ) + assert( + curriedStore.getState().counterReducer, + _ as { + readonly counter: number + } + ) + }) } } -function subFromCounter(subNumber: number) { - return { - type: "SUB_FROM_COUNTER", - payload: subNumber +it("works with inferred curried reducer", () => { + type State = { + count: number } -} -const reduceCounterProducer = (state: State = initialState, action: Action) => - produce(state, draftState => { - switch (action.type) { - case "ADD_TO_COUNTER": - draftState.counter += action.payload - break - case "SUB_FROM_COUNTER": - draftState.counter -= action.payload - break - } - }) + type Action = { + type: "inc" + count: number + } -const reduceCounterCurriedProducer = produce( - (draftState: Draft, action: Action) => { - switch (action.type) { - case "ADD_TO_COUNTER": - draftState.counter += action.payload - break - case "SUB_FROM_COUNTER": - draftState.counter -= action.payload - break - } - }, - initialState -) + const defaultState = { + count: 3 + } -/// =============== Reducers + const store = redux.createStore( + produce((state: State, action: Action) => { + if (action.type === "inc") state.count += action.count + // @ts-expect-error + state.count2 + }, defaultState) + ) -const reduce = redux.combineReducers({ - counterReducer: reduceCounterProducer -}) + assert(store.getState(), _ as State) + store.dispatch({ + type: "inc", + count: 2 + }) -const curredReduce = redux.combineReducers({ - counterReducer: reduceCounterCurriedProducer + store.dispatch({ + // @ts-expect-error + type: "inc2", + count: 2 + }) }) -// reducing the current state to get the next state! -// console.log(reduce(initialState, addToCounter(12)); +it("works with inferred curried reducer - readonly", () => { + type State = { + readonly count: number + } -// ================ store + type Action = { + readonly type: "inc" + readonly count: number + } -const store = redux.createStore(reduce) -const curriedStore = redux.createStore(curredReduce) + const defaultState: State = { + count: 3 + } -it("#470 works with Redux combine reducers", () => { - assert( - store.getState().counterReducer, - _ as { - counter: number - } - ) - assert( - curriedStore.getState().counterReducer, - _ as { - readonly counter: number - } + const store = redux.createStore( + produce((state: Draft, action: Action) => { + if (action.type === "inc") state.count += action.count + // @ts-expect-error + state.count2 + }, defaultState) ) + + assert(store.getState(), _ as State) + store.dispatch({ + type: "inc", + count: 2 + }) + + store.dispatch({ + // @ts-expect-error + type: "inc2", + count: 2 + }) }) diff --git a/package.json b/package.json index 21a969cc..109dd05e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "immer", - "version": "7.0.0-beta.0", + "version": "9.0.0-beta.1", "description": "Create your next immutable state by mutating the current one", "main": "dist/index.js", "module": "dist/immer.esm.js", @@ -92,7 +92,7 @@ "spec.ts": "^1.1.0", "ts-jest": "^25.2.0", "tsdx": "^0.12.3", - "typescript": "^3.9.3", + "typescript": "^4.2.3", "webpack": "^4.41.6", "webpack-cli": "^3.3.11" } diff --git a/src/types/types-external.ts b/src/types/types-external.ts index 53a73afa..c8ef906b 100644 --- a/src/types/types-external.ts +++ b/src/types/types-external.ts @@ -1,11 +1,6 @@ import {Nothing} from "../internal" -type Tail = ((...t: T) => any) extends ( - _: any, - ...tail: infer TT -) => any - ? TT - : [] +type AnyFunc = (...args: any[]) => any type PrimitiveType = number | string | boolean @@ -84,6 +79,79 @@ export type Produced = Return extends void ? Promise> : FromNothing +/** + * Utility types + */ +type PatchesTuple = readonly [T, Patch[], Patch[]] + +type ValidRecipeReturnType = + | State + | void + | undefined + | (State extends undefined ? Nothing : never) + +type ValidRecipeReturnTypePossiblyPromise = + | ValidRecipeReturnType + | Promise> + +type PromisifyReturnIfNeeded< + State, + Recipe extends AnyFunc, + UsePatches extends boolean +> = ReturnType extends Promise + ? Promise : State> + : UsePatches extends true + ? PatchesTuple + : State + +/** + * Core Producer inference + */ +type InferRecipeFromCurried = Curried extends ( + base: infer State, + ...rest: infer Args +) => any // extra assertion to make sure this is a proper curried function (state, args) => state + ? ReturnType extends State + ? ( + draft: Draft, + ...rest: Args + ) => ValidRecipeReturnType> + : never + : never + +type InferInitialStateFromCurried = Curried extends ( + base: infer State, + ...rest: any[] +) => any // extra assertion to make sure this is a proper curried function (state, args) => state + ? State + : never + +type InferCurriedFromRecipe< + Recipe, + UsePatches extends boolean +> = Recipe extends (draft: infer DraftState, ...args: infer RestArgs) => any // verify return type + ? ReturnType extends ValidRecipeReturnTypePossiblyPromise + ? ( + base: Immutable, + ...args: RestArgs + ) => PromisifyReturnIfNeeded // N.b. we return mutable draftstate, in case the recipe's first arg isn't read only, and that isn't expected as output either + : never // incorrect return type + : never // not a function + +type InferCurriedFromInitialStateAndRecipe< + State, + Recipe, + UsePatches extends boolean +> = Recipe extends ( + draft: Draft, + ...rest: infer RestArgs +) => ValidRecipeReturnTypePossiblyPromise + ? ( + base?: State | undefined, + ...args: RestArgs + ) => PromisifyReturnIfNeeded + : never // recipe doesn't match initial state + /** * The `produce` function takes a value and a "recipe function" (whose * return value often depends on the base state). The recipe function is @@ -104,40 +172,58 @@ export type Produced = Return extends void * @returns {any} a new state, or the initial state if nothing was modified */ export interface IProduce { - /** Curried producer */ - < - Recipe extends (...args: any[]) => any, - Params extends any[] = Parameters, - T = Params[0] - >( - recipe: Recipe - ): >( - base: Base, - ...rest: Tail - ) => Produced> - // ^ by making the returned type generic, the actual type of the passed in object is preferred - // over the type used in the recipe. However, it does have to satisfy the immutable version used in the recipe - // Note: the type of S is the widened version of T, so it can have more props than T, but that is technically actually correct! - - /** Curried producer with initial state */ - < - Recipe extends (...args: any[]) => any, - Params extends any[] = Parameters, - T = Params[0] - >( + /** Curried producer that infers the recipe from the curried output function (e.g. when passing to setState) */ + ( + recipe: InferRecipeFromCurried, + initialState?: InferInitialStateFromCurried + ): Curried + + /** Curried producer that infers curried from the recipe */ + (recipe: Recipe): InferCurriedFromRecipe< + Recipe, + false + > + + /** Curried producer that infers curried from the State generic, which is explicitly passed in. */ + ( + recipe: ( + state: Draft, + initialState: State + ) => ValidRecipeReturnType + ): (state?: State) => State + ( + recipe: ( + state: Draft, + ...args: Args + ) => ValidRecipeReturnType, + initialState: State + ): (state?: State, ...args: Args) => State + (recipe: (state: Draft) => ValidRecipeReturnType): ( + state: State + ) => State + ( + recipe: (state: Draft, ...args: Args) => ValidRecipeReturnType + ): (state: State, ...args: Args) => State + + /** Curried producer with initial state, infers recipe from initial state */ + ( recipe: Recipe, - initialState: Immutable - ): >( - base?: Base, - ...rest: Tail - ) => Produced> + initialState: State + ): InferCurriedFromInitialStateAndRecipe + + /** Promisified dormal producer */ + >( + base: Base, + recipe: (draft: D) => Promise>, + listener?: PatchListener + ): Promise /** Normal producer */ - , Return = void>( + >( // By using a default inferred D, rather than Draft in the recipe, we can override it. base: Base, - recipe: (draft: D) => Return, + recipe: (draft: D) => ValidRecipeReturnType, listener?: PatchListener - ): Produced + ): Base } /** @@ -147,39 +233,22 @@ export interface IProduce { * Like produce, this function supports currying */ export interface IProduceWithPatches { - /** Curried producer */ - < - Recipe extends (...args: any[]) => any, - Params extends any[] = Parameters, - T = Params[0] - >( - recipe: Recipe - ): >( - base: Base, - ...rest: Tail - ) => [Produced>, Patch[], Patch[]] - // ^ by making the returned type generic, the actual type of the passed in object is preferred - // over the type used in the recipe. However, it does have to satisfy the immutable version used in the recipe - // Note: the type of S is the widened version of T, so it can have more props than T, but that is technically actually correct! - - /** Curried producer with initial state */ - < - Recipe extends (...args: any[]) => any, - Params extends any[] = Parameters, - T = Params[0] - >( + // Types copied from IProduce, wrapped with PatchesTuple + (recipe: Recipe): InferCurriedFromRecipe + ( recipe: Recipe, - initialState: Immutable - ): >( - base?: Base, - ...rest: Tail - ) => [Produced>, Patch[], Patch[]] - - /** Normal producer */ - , Return = void>( + initialState: State + ): InferCurriedFromInitialStateAndRecipe + >( base: Base, - recipe: (draft: D) => Return - ): [Produced, Patch[], Patch[]] + recipe: (draft: D) => ValidRecipeReturnType, + listener?: PatchListener + ): PatchesTuple> + >( + base: Base, + recipe: (draft: D) => Promise>, + listener?: PatchListener + ): PatchesTuple>> } // Fixes #507: bili doesn't export the types of this file if there is no actual source in it.. diff --git a/website/docs/return.mdx b/website/docs/return.mdx index d1c7e55d..117c12b2 100644 --- a/website/docs/return.mdx +++ b/website/docs/return.mdx @@ -9,7 +9,8 @@ title: Returning new data from producers data-ea-type="image" className="horizontal bordered" > -
+ +
egghead.io lesson 9: Returning completely new state @@ -111,6 +112,8 @@ produce(state, draft => nothing) N.B. Note that this problem is specific for the `undefined` value, any other value, including `null`, doesn't suffer from this issue. +Tip: to be able to return `nothing` from a recipe when using TypeScript, the `state`'s type must accept `undefined` as value. + ## Inline shortcuts using `void`
diff --git a/website/docs/typescript.mdx b/website/docs/typescript.mdx index fd7e2cc9..d24fa91f 100644 --- a/website/docs/typescript.mdx +++ b/website/docs/typescript.mdx @@ -10,7 +10,8 @@ sidebar_label: TypeScript / Flow data-ea-type="image" className="horizontal bordered" > -
+ +
egghead.io lesson 12: Immer + TypeScript @@ -55,32 +56,96 @@ const newState = produce(state, draft => { // `newState.x` cannot be modified here ``` -This ensures that the only place you can modify your state is in your produce callbacks. It even works recursively and with `ReadonlyArray`! +This ensures that the only place you can modify your state is in your produce callbacks. It even works recursively and with `ReadonlyArray`. -For curried reducers, the type is inferred from the first argument of recipe function, so make sure to type it. The `Draft` utility type can be used if the state argument type is immutable: +## Best practices -```ts -import produce, {Draft} from "immer" +1. Always define your states as `readonly` as much as possible. This best reflects the mental model and reality, since Immer will freeze all its returned values. +2. You can use the utility type `Immutable` to recursively make an entire type tree read-only, e.g.: `type ReadonlyState = Immutable`. +3. Immer won't automatically wrap all returned types in `Immutable` if the original type of the input state wasn't immutable. This is to make sure it doesn't break code bases that don't use immutable types. -interface State { - readonly x: number -} +## Tips for curried producers -// `x` cannot be modified here -const state: State = { - x: 0 -} +We try to inference as much as possible. So if a curried producer is created and directly passed to another function, we can infer the type from there. This works will with for example React: -const increment = produce((draft: Draft, inc: number) => { - // `x` can be modified here - draft.x += inc +```typescript +import {Immutable, produce} from "immer" + +type Todo = Immutable<{ + title: string + done: boolean +}> + +// later... + +const [todo, setTodo] = useState({ + title: "test", + done: true }) -const newState = increment(state, 2) -// `newState.x` cannot be modified here +// later... + +setTodo( + produce(draft => { + // draft will be strongly typed and mutable! + draft.done = !draft.done + }) +) +``` + +When a curried producer isn't passed directly somewhere else, Immer can infer the state type from the draft argument. For example when doing the following: + +```typescript +// See below for a better solution! + +const toggler = produce((draft: Draft) => { + draft.done = !draft.done +}) + +// typeof toggler = (state: Immutable) => Writable +``` + +Note that we did wrap the `Todo` type of the `draft` argument with `Draft`, because `Todo` is a readonly type. For non-readonly types this isn't needed. + +For the returned curried function, `toggler`, We will _narrow_ the _input_ type to `Immutable`, so that even though `Todo` is a mutable type, we will still accept an immutable todo as input argument to `toggler`. + +In contrast, Immer will _widen_ the _output_ type of the curried function to `Writable`, to make sure it's output state is also assignable to variables that are not explictly typed to be immutable. + +This type narrowing / widening behavior might be unwelcome, maybe even for the simpe reason that it results in quite noisy types. So we recommend to specify the generic state type for curried producers instead, in cases where it cannot be inferred directly, like `toggler` above. By doing so the automatic output widening / input narrowing will be skipped. However, the `draft` argument itself will still be inferred to be a writable `Draft`: + +```typescript +const toggler = produce(draft => { + draft.done = !draft.done +}) + +// typeof toggler = (state: Todo) => Todo +``` + +However, in case the curried producer is defined with an initial state, Immer can infer the state type from the initial state, so in that case the generic doesn't need to be specified either: + +```typescript +const state0: Todo = { + title: "test", + done: false +} + +// No type annotations needed, since we can infer from state0. +const toggler = produce(draft => { + draft.done = !draft.done +}, state0) + +// typeof toggler = (state: Todo) => Todo ``` -_Note: Since TypeScript support for recursive types is limited, and there is no co- contravariance, it might the easiest to not type your state as `readonly` (Immer will still protect against accidental mutations)_ +In case the toggler has no initial state, and it has curried arguments, and you set the state generic explicitly, then type of any additional arguments should be defined explicitly as a tuple type as well: + +```typescript +const toggler = produce((draft, newState) => { + draft.done = newState +}) + +// typeof toggler = (state: Todo, newState: boolean) => Todo +``` ## Cast utilities diff --git a/yarn.lock b/yarn.lock index 5c91e700..7e52ed0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8503,7 +8503,6 @@ npm@^6.10.3: cmd-shim "^3.0.3" columnify "~1.5.4" config-chain "^1.1.12" - debuglog "*" detect-indent "~5.0.0" detect-newline "^2.1.0" dezalgo "~1.0.3" @@ -8518,7 +8517,6 @@ npm@^6.10.3: has-unicode "~2.0.1" hosted-git-info "^2.8.8" iferr "^1.0.2" - imurmurhash "*" infer-owner "^1.0.4" inflight "~1.0.6" inherits "^2.0.4" @@ -8537,14 +8535,8 @@ npm@^6.10.3: libnpx "^10.2.4" lock-verify "^2.1.0" lockfile "^1.0.4" - lodash._baseindexof "*" lodash._baseuniq "~4.6.0" - lodash._bindcallback "*" - lodash._cacheindexof "*" - lodash._createcache "*" - lodash._getnative "*" lodash.clonedeep "~4.5.0" - lodash.restparam "*" lodash.union "~4.6.0" lodash.uniq "~4.5.0" lodash.without "~4.4.0" @@ -11748,10 +11740,10 @@ typescript@^3.7.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== -typescript@^3.9.3: - version "3.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.3.tgz#d3ac8883a97c26139e42df5e93eeece33d610b8a" - integrity sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ== +typescript@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" + integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== uglify-js@^3.1.4: version "3.7.7"