diff --git a/__tests__/test.tsconfig.json b/__tests__/test.tsconfig.json new file mode 100644 index 00000000..391488ab --- /dev/null +++ b/__tests__/test.tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "commonjs" + } +} diff --git a/__tests__/types.ts b/__tests__/types.ts new file mode 100644 index 00000000..5b3cc530 --- /dev/null +++ b/__tests__/types.ts @@ -0,0 +1,68 @@ +import produce from '../src/immer'; + +interface State { + readonly num: number; + readonly foo?: string; + bar: string; + readonly baz: { + readonly x: number; + readonly y: number; + }; + readonly arr: ReadonlyArray<{ readonly value: string }>; + readonly arr2: { readonly value: string }[]; +} + +const state: State = { + num: 0, + bar: 'foo', + baz: { + x: 1, + y: 2, + }, + arr: [{ value: 'asdf' }], + arr2: [{ value: 'asdf' }], +}; + +const expectedState: State = { + num: 1, + foo: 'bar', + bar: 'foo', + baz: { + x: 2, + y: 3, + }, + arr: [{ value: 'foo' }, { value: 'asf' }], + arr2: [{ value: 'foo' }, { value: 'asf' }], +}; + +it('can update readonly state via standard api', () => { + const newState = produce(state, draft => { + draft.num++; + draft.foo = 'bar'; + draft.bar = 'foo'; + draft.baz.x++; + draft.baz.y++; + draft.arr[0].value = 'foo'; + draft.arr.push({ value: 'asf' }); + draft.arr2[0].value = 'foo'; + draft.arr2.push({ value: 'asf' }); + }); + expect(newState).not.toBe(state); + expect(newState).toEqual(expectedState); +}); + +it('can update readonly state via curried api', () => { + const newState = produce(draft => { + draft.num++; + draft.foo = 'bar'; + draft.bar = 'foo'; + draft.baz.x++; + draft.baz.y++; + draft.arr[0].value = 'foo'; + draft.arr.push({ value: 'asf' }); + draft.arr2[0].value = 'foo'; + draft.arr2.push({ value: 'asf' }); + })(state); + expect(newState).not.toBe(state); + expect(newState).toEqual(expectedState); +}); diff --git a/package.json b/package.json index e722af95..f4366631 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "dist/" ], "devDependencies": { - "@types/jest": "^22.0.0", + "@types/jest": "^22.2.3", "babel-cli": "^6.26.0", "babel-core": "^6.26.0", "babel-jest": "^22.0.4", @@ -68,7 +68,8 @@ "rollup-plugin-filesize": "^1.5.0", "rollup-plugin-node-resolve": "^3.0.2", "rollup-plugin-uglify": "^2.0.1", - "typescript": "^2.6.2", + "ts-jest": "^22.4.6", + "typescript": "^2.9.1", "uglify-es": "^3.3.6", "yarn-or-npm": "^2.0.4" }, @@ -80,7 +81,23 @@ }, "jest": { "transform": { - "^.+\\.jsx?$": "babel-jest" - } + "^.+\\.jsx?$": "babel-jest", + "^.+\\.tsx?$": "ts-jest" + }, + "testRegex": "/__tests__/[^/]*[jt]sx?$", + "globals": { + "ts-jest": { + "enableTsDiagnostics": true, + "tsConfigFile": "__tests__/test.tsconfig.json" + } + }, + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ] } } diff --git a/readme.md b/readme.md index 07dafc31..90f580cd 100644 --- a/readme.md +++ b/readme.md @@ -289,6 +289,30 @@ console.log(increment(base).counter) // 1 The Immer package ships with type definitions inside the package, which should be picked up by TypeScript and Flow out of the box and without further configuration. +The TypeScript typings automatically remove `readonly` modifiers from your draft types and return a value that matches your original type. See this practical example: + +```ts +import produce from 'immer'; + +interface State { + readonly x: number; +} + +// `x` cannot be modified here +const state: State = { + x: 0; +}; + +const newState = produce(draft => { + // `x` can be modified here + draft.x++; +}); + +// `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`s! + ## Immer on older JavaScript environments? By default `produce` tries to use proxies for optimal performance. However, on older JavaScript engines `Proxy` is not available. For example, when running Microsoft Internet Explorer or React Native on Android. In such cases Immer will fallback to an ES5 compatible implementation which works identical, but is a bit slower. diff --git a/src/immer.d.ts b/src/immer.d.ts index e5e58390..51700ee5 100644 --- a/src/immer.d.ts +++ b/src/immer.d.ts @@ -1,3 +1,15 @@ +// Mapped type to remove readonly modifiers from state +// Based on https://github.com/Microsoft/TypeScript/blob/d4dc67aab233f5a8834dff16531baf99b16fea78/tests/cases/conformance/types/conditional/conditionalTypes1.ts#L120-L129 +export type DraftObject = { + -readonly [P in keyof T]: Draft; +}; +export interface DraftArray extends Array> { } +export type Draft = + T extends any[] ? DraftArray : + T extends ReadonlyArray ? DraftArray : + T extends object ? DraftObject : + T; + /** * Immer takes a state, and runs a function against it. * That function can freely mutate the state, as it will create copies-on-write. @@ -13,60 +25,60 @@ */ export default function( currentState: S, - recipe: (this: S, draftState: S) => void | S + recipe: (this: Draft, draftState: Draft) => void | S ): S // curried invocations with default initial state // 0 additional arguments export default function( - recipe: (this: S, draftState: S) => void | S, + recipe: (this: Draft, draftState: Draft) => void | S, initialState: S ): (currentState: S | undefined) => S // 1 additional argument of type A export default function( - recipe: (this: S, draftState: S, a: A) => void | S, + recipe: (this: Draft, draftState: Draft, a: A) => void | S, initialState: S ): (currentState: S | undefined, a: A) => S // 2 additional arguments of types A and B export default function( - recipe: (this: S, draftState: S, a: A, b: B) => void | S, + recipe: (this: Draft, draftState: Draft, a: A, b: B) => void | S, initialState: S ): (currentState: S | undefined, a: A, b: B) => S // 3 additional arguments of types A, B and C export default function( - recipe: (this: S, draftState: S, a: A, b: B, c: C) => void | S, + recipe: (this: Draft, draftState: Draft, a: A, b: B, c: C) => void | S, initialState: S ): (currentState: S | undefined, a: A, b: B, c: C) => S // any number of additional arguments, but with loss of type safety // this may be alleviated if "variadic kinds" makes it into Typescript: // https://github.com/Microsoft/TypeScript/issues/5453 export default function( - recipe: (this: S, draftState: S, ...extraArgs: any[]) => void | S, + recipe: (this: Draft, draftState: Draft, ...extraArgs: any[]) => void | S, initialState: S ): (currentState: S | undefined, ...extraArgs: any[]) => S // curried invocations without default initial state // 0 additional arguments export default function( - recipe: (this: S, draftState: S) => void | S + recipe: (this: Draft, draftState: Draft) => void | S ): (currentState: S) => S // 1 additional argument of type A export default function( - recipe: (this: S, draftState: S, a: A) => void | S + recipe: (this: Draft, draftState: Draft, a: A) => void | S ): (currentState: S, a: A) => S // 2 additional arguments of types A and B export default function( - recipe: (this: S, draftState: S, a: A, b: B) => void | S + recipe: (this: Draft, draftState: Draft, a: A, b: B) => void | S ): (currentState: S, a: A, b: B) => S // 3 additional arguments of types A, B and C export default function( - recipe: (this: S, draftState: S, a: A, b: B, c: C) => void | S + recipe: (this: Draft, draftState: Draft, a: A, b: B, c: C) => void | S ): (currentState: S, a: A, b: B, c: C) => S // any number of additional arguments, but with loss of type safety // this may be alleviated if "variadic kinds" makes it into Typescript: // https://github.com/Microsoft/TypeScript/issues/5453 export default function( - recipe: (this: S, draftState: S, ...extraArgs: any[]) => void | S + recipe: (this: Draft, draftState: Draft, ...extraArgs: any[]) => void | S ): (currentState: S, ...extraArgs: any[]) => S /** * Automatically freezes any state trees generated by immer.