Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved types for TypeScript 3.7 #506

Merged
merged 10 commits into from
Jan 14, 2020
14 changes: 14 additions & 0 deletions __tests__/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,20 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) {
expect(d instanceof Map).toBeTruthy()
})
})

it("handles clear correctly", () => {
const map = new Map([
["a", 1],
["c", 3]
])
const next = produce(map, draft => {
draft.delete("a")
draft.set("b", 2)
draft.set("c", 4)
draft.clear()
})
expect(next).toEqual(new Map())
})
})

describe("set drafts", () => {
Expand Down
43 changes: 42 additions & 1 deletion __tests__/draft.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {assert, _} from "spec.ts"
import {Draft} from "../src/index"
import produce, {Draft, castDraft, original} from "../src/index"

// For checking if a type is assignable to its draft type (and vice versa)
const toDraft: <T>(value: T) => Draft<T> = x => x as any
Expand Down Expand Up @@ -187,6 +187,13 @@ test("draft.ts", () => {
assert(fromDraft(toDraft(weak)), weak)
}

// ReadonlyMap instance
{
let val: ReadonlyMap<any, any> = _
let draft: Map<any, any> = _
assert(toDraft(val), draft)
}

// Set instance
{
let val: Set<any> = _
Expand All @@ -199,6 +206,13 @@ test("draft.ts", () => {
assert(fromDraft(toDraft(weak)), weak)
}

// ReadonlySet instance
{
let val: ReadonlySet<any> = _
let draft: Set<any> = _
assert(toDraft(val), draft)
}

// Promise object
{
let val: Promise<any> = _
Expand Down Expand Up @@ -289,3 +303,30 @@ test("draft.ts", () => {

expect(true).toBe(true)
})

test("asDraft", () => {
type Todo = {readonly done: boolean}

type State = {
readonly finishedTodos: ReadonlyArray<Todo>
readonly unfinishedTodos: ReadonlyArray<Todo>
}

function markAllFinished(state: State) {
produce(state, draft => {
draft.finishedTodos = castDraft(state.unfinishedTodos)
})
}
})

test("#505 original", () => {
const baseState = {users: [{name: "Richie"}] as const}
const nextState = produce(baseState, draftState => {
original(draftState.users) === baseState.users
})
})

test("asDraft preserves a value", () => {
const x = {}
expect(castDraft(x)).toBe(x)
})
59 changes: 58 additions & 1 deletion __tests__/immutable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {assert, _} from "spec.ts"
import {Immutable} from "../src/index"
import produce, {Immutable, castImmutable} from "../src/index"

test("types are ok", () => {
// array in tuple
Expand Down Expand Up @@ -56,5 +56,62 @@ test("types are ok", () => {
assert(val, _ as {readonly a: {readonly b: string}})
}

// Map
{
let val = _ as Immutable<Map<string, string>>
assert(val, _ as ReadonlyMap<string, string>)
}

// Already immutable Map
{
let val = _ as Immutable<ReadonlyMap<string, string>>
assert(val, _ as ReadonlyMap<string, string>)
}

// object in Map
{
let val = _ as Immutable<Map<{a: string}, {b: string}>>
assert(val, _ as ReadonlyMap<{readonly a: string}, {readonly b: string}>)
}

// Set
{
let val = _ as Immutable<Set<string>>
assert(val, _ as ReadonlySet<string>)
}

// Already immutable Set
{
let val = _ as Immutable<ReadonlySet<string>>
assert(val, _ as ReadonlySet<string>)
}

// object in Set
{
let val = _ as Immutable<Set<{a: string}>>
assert(val, _ as ReadonlySet<{readonly a: string}>)
}

expect(true).toBe(true)
})

test("#381 produce immutable state", () => {
const someState = {
todos: [
{
done: false
}
]
}

const immutable = castImmutable(produce(someState, _draft => {}))
assert(
immutable,
_ as {readonly todos: ReadonlyArray<{readonly done: boolean}>}
)
})

test("castImmutable preserves a value", () => {
const x = {}
expect(castImmutable(x)).toBe(x)
})
20 changes: 18 additions & 2 deletions __tests__/produce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,28 @@ it("works with readonly Map and Set", () => {
const s = new Set<S>([{x: 2}])

const res1 = produce(m, (draft: Draft<Map<string, S>>) => {
assert(draft, _ as Map<string, {readonly x: number}>) // TODO: drop readonly in TS 3.7
assert(draft, _ as Map<string, {x: number}>)
})
assert(res1, _ as Map<string, {readonly x: number}>)

const res2 = produce(s, (draft: Draft<Set<S>>) => {
assert(draft, _ as Set<{readonly x: number}>) // TODO: drop readonly in TS 3.7
assert(draft, _ as Set<{x: number}>)
})
assert(res2, _ as Set<{readonly x: number}>)
})

it("works with ReadonlyMap and ReadonlySet", () => {
type S = {readonly x: number}
const m: ReadonlyMap<string, S> = new Map([["a", {x: 1}]])
const s: ReadonlySet<S> = new Set([{x: 2}])

const res1 = produce(m, (draft: Draft<Map<string, S>>) => {
assert(draft, _ as Map<string, {x: number}>)
})
assert(res1, _ as ReadonlyMap<string, {readonly x: number}>)

const res2 = produce(s, (draft: Draft<Set<S>>) => {
assert(draft, _ as Set<{x: number}>)
})
assert(res2, _ as ReadonlySet<{readonly x: number}>)
})
101 changes: 101 additions & 0 deletions __tests__/redux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {assert, _} from "spec.ts"
import produce, {
produce as produce2,
applyPatches,
Patch,
nothing,
Draft,
Immutable
} from "../src/"
import * as redux from "redux"

export interface State {
counter: number
}

export interface Action {
type: string
payload: number
}

export const initialState: State = {
counter: 0
}

/// =============== Actions

export function addToCounter(addNumber: number) {
return {
type: "ADD_TO_COUNTER",
payload: addNumber
}
}

export function subFromCounter(subNumber: number) {
return {
type: "SUB_FROM_COUNTER",
payload: subNumber
}
}

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

export const reduceCounterCurriedProducer = produce(
(draftState: Draft<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

export const reduce = redux.combineReducers({
counterReducer: reduceCounterProducer
})

export const curredReduce = redux.combineReducers({
counterReducer: reduceCounterCurriedProducer
})

// reducing the current state to get the next state!
// console.log(reduce(initialState, addToCounter(12));

// ================ store

export const store = redux.createStore(reduce)
export const curriedStore = redux.createStore(curredReduce)

it("#470 works with Redux combine reducers", () => {
assert(
store.getState().counterReducer,
_ as {
counter: number
}
)
assert(
curriedStore.getState().counterReducer,
_ as {
readonly counter: number
}
)
})
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ title: API overview
| Exported name | Description | Section |
| --- | --- | --- |
| `applyPatches` | Given a base state or draft, and a set of patches, applies the patches | [Patches](patches.md) |
| `castDraft` | Converts any immutable type to its mutable counterpart. This is just a cast and doesn't actually do anything. | [TypeScript](typescript.md) |
| `castImmutable` | Converts any mutable type to its immutable counterpart. This is just a cast and doesn't actually do anything. | [TypeScript](typescript.md) |
| `createDraft` | Given a base state, creates a mutable draft for which any modifications will be recorded | [Async](async.md) |
| `Draft<T>` | Exposed TypeScript type to convert an immutable type to a mutable type | [TypeScript](typescript.md) |
| `finishDraft` | Given an draft created using `createDraft`, seals the draft and produces and returns the next immutable state that captures all the changes | [Async](async.md) |
Expand Down
57 changes: 57 additions & 0 deletions docs/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,63 @@ const newState = increment(state, 2)

_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)_

## Cast utilities

The types inside and outside a `produce` can be conceptually the same, but from a practical perspective different. For example, the `State` in the examples above should be considered immutable outside `produce`, but mutable inside `produce`.

Sometimes this leads to practical conflicts. Take the following example:

```typescript
type Todo = {readonly done: boolean}

type State = {
readonly finishedTodos: readonly Todo[]
readonly unfinishedTodos: readonly Todo[]
}

function markAllFinished(state: State) {
produce(state, draft => {
draft.finishedTodos = state.unfinishedTodos
})
}
```

This will generate the error:

```
The type 'readonly Todo[]' is 'readonly' and cannot be assigned to the mutable type '{ done: boolean; }[]'
```

The reason for this error is that we assing our read only, immutable array to our draft, which expects a mutable type, with methods like `.push` etc etc. As far as TS is concerned, those are not exposed from our original `State`. To hint TypeScript that we want to upcast the collection here to a mutable array for draft purposes, we can use the utility `asDraft`:

`draft.finishedTodos = castDraft(state.unfinishedTodos)` will make the error disappear.

There is also the utility `castImmutable`, in case you ever need to achieve the opposite. Note that these utilities are for all practical purposes no-ops, they will just return their original value.

Tip: You can combine `castImmutable` with `produce` to type the return type of `produce` as something immutable, even when the original state was mutable:

```typescript
// a mutable data structure
const baseState = {
todos: [{
done: false
}]
}

const nextState = castImmutable(produce(baseState, _draft => {}))

// inferred type of nextState is now:
{
readonly todos: ReadonlyArray<{
readonly done: boolean
}>
})
```

## Compatibility

**Note:** Immer v5.3+ supports TypeScript v3.7+ only.

**Note:** Immer v1.9+ supports TypeScript v3.1+ only.

**Note:** Immer v3.0+ supports TypeScript v3.4+ only.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:perf": "NODE_ENV=production yarn-or-npm build && cd __performance_tests__ && babel-node add-data.js && babel-node todo.js && babel-node incremental.js",
"test:flow": "yarn-or-npm flow check __tests__/flow",
"watch": "jest --watch",
"coverage": "jest --coverage",
"coveralls": "jest --coverage && cat ./coverage/lcov.info | ./node_modules/.bin/coveralls && rm -rf ./coverage",
"build": "rimraf dist/ && yarn-or-npm bili && yarn-or-npm typed",
"typed": "cpx 'src/immer.js.flow' dist -v",
Expand Down Expand Up @@ -72,6 +73,7 @@
"lodash.clonedeep": "^4.5.0",
"prettier": "1.19.1",
"pretty-quick": "^1.8.0",
"redux": "^4.0.5",
"regenerator-runtime": "^0.11.1",
"rimraf": "^2.6.2",
"rollup-plugin-typescript2": "^0.25.3",
Expand Down
3 changes: 2 additions & 1 deletion src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function isPlainObject(value: any): boolean {
}

/** Get the underlying object that is represented by the given draft */
export function original<T>(value: Drafted<T>): T | undefined {
export function original<T>(value: T): T | undefined
export function original(value: Drafted<any>): any {
if (value && value[DRAFT_STATE]) {
return value[DRAFT_STATE].base as any
}
Expand Down
1 change: 1 addition & 0 deletions src/extends.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* istanbul ignore next */
var extendStatics = function(d: any, b: any): any {
extendStatics =
Object.setPrototypeOf ||
Expand Down