Skip to content

Commit

Permalink
docs: a new explanation why the type can not be inferred automatically
Browse files Browse the repository at this point in the history
  • Loading branch information
congqian committed Dec 14, 2024
1 parent 459d789 commit 5704656
Showing 1 changed file with 28 additions and 42 deletions.
70 changes: 28 additions & 42 deletions docs/guides/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,65 +26,51 @@ const useBearStore = create<BearState>()((set) => ({

<br/>

**TLDR**: Because state generic `T` is invariant.
**TLDR**: Because we just provide the information for the returned type but no information for the parameter type.

Consider this minimal version `create`:

```ts
``` ts
declare const create: <T>(f: (get: () => T) => T) => T

const x = create((get) => ({
const x = create(() => ({
foo: 0,
bar: () => get(),
bar: ''
}))
// `x` is inferred as `unknown` instead of
// interface X {
// foo: number,
// bar: () => X
// const x: {
// foo: number;
// bar: string;
// }
```

Here, if you look at the type of `f` in `create`, i.e. `(get: () => T) => T`, it "gives" `T` via return (making it covariant), but it also "takes" `T` via `get` (making it contravariant). "So where does `T` come from?" TypeScript wonders. It's like that chicken or egg problem. At the end TypeScript, gives up and infers `T` as `unknown`.

So, as long as the generic to be inferred is invariant (i.e. both covariant and contravariant), TypeScript will be unable to infer it. Another simple example would be this:
Here, if we do not provide a `get` param for `f` function, `x` can be inferred properly. However, if we provide the `get` function

```ts
const createFoo = {} as <T>(f: (t: T) => T) => T
const x = createFoo((_) => 'hello')
```

Here again, `x` is `unknown` instead of `string`.

<details>
<summary>More about the inference (just for the people curious and interested in TypeScript)</summary>

In some sense this inference failure is not a problem because a value of type `<T>(f: (t: T) => T) => T` cannot be written. That is to say you can't write the real runtime implementation of `createFoo`. Let's try it:

```js
const createFoo = (f) => f(/* ? */)
declare const create: <T>(f: (get: () => T) => T) => T
const x = create((get) => ({
foo: 0,
bar: ''
}))
// const x: unknown
```
`x` is inferred as `unknown`, why the difference? Let us walk through how TS Engine binds the type.

`createFoo` needs to return the return value of `f`. And to do that we first have to call `f`. And to call it we have to pass a value of type `T`. And to pass a value of type `T` we first have to produce it. But how can we produce a value of type `T` when we don't even know what `T` is? The only way to produce a value of type `T` is to call `f`, but then to call `f` itself we need a value of type `T`. So you see it's impossible to actually write `createFoo`.

So what we're saying is, the inference failure in case of `createFoo` is not really a problem because it's impossible to implement `createFoo`. But what about the inference failure in case of `create`? That also is not really a problem because it's impossible to implement `create` too. Wait a minute, if it's impossible to implement `create` then how does Zustand implement it? The answer is, it doesn't.
In the first case, the engine try to match `() => ({foo: 0, bar: ''})` and `(get: () => T) => T`, based on the return type, the engine binds `T` with `{foo: 0, bar: ''}`, after doing that, all the constraint are met, it will not bother to match the parameter type because there is no parameter and will always match. Remember that `()=>number` is perfectly assignable to `(x:number)=>number`

Zustand lies that it implemented `create`'s type, it implemented only the most part of it. Here's a simple proof by showing unsoundness. Consider the following code:
In the second case, the engine tries to match `(get) => ({foo: 0, bar: ''})` and `(get: () => T) => T`, This time, the engine sees the `get` parameter is provided, so it **must** match it. However, `get` has an implicit `any` type without other hint to match `()=>T`, so `T` is inferred as `unknown`. Now, `T` is inferred as `unknown` in the parameter type and `{foo: 0, bar: ''}` in the return type. Finally, `T` is inferred as `unknown | {foo: 0, bar: ''}` which is `unknown`.

```ts
import { create } from 'zustand'
If we provide some hints for the parameter type, it works again

const useBoundStore = create<{ foo: number }>()((_, get) => ({
foo: get().foo,
``` ts
const x = create((get:()=>{ foo:number }) => ({
foo: 0,
bar: ''
}))
// {
// foo: number;
// bar: string;
// }
```

This code compiles. But if we run it, we'll get an exception: "Uncaught TypeError: Cannot read properties of undefined (reading 'foo')". This is because `get` would return `undefined` before the initial state is created (hence you shouldn't call `get` when creating the initial state). The types promise that `get` will never return `undefined` but it does initially, which means Zustand failed to implement it.

And of course Zustand failed because it's impossible to implement `create` the way types promise (in the same way it's impossible to implement `createFoo`). In other words we don't have a type to express the actual `create` we have implemented. We can't type `get` as `() => T | undefined` because it would cause inconvenience and it still won't be correct as `get` is indeed `() => T` eventually, just if called synchronously it would be `() => undefined`. What we need is some kind of TypeScript feature that allows us to type `get` as `(() => T) & WhenSync<() => undefined>`, which of course is extremely far-fetched.

So we have two problems: lack of inference and unsoundness. Lack of inference can be solved if TypeScript can improve its inference for invariants. And unsoundness can be solved if TypeScript introduces something like `WhenSync`. To work around lack of inference we manually annotate the state type. And we can't work around unsoundness, but it's not a big deal because it's not much, calling `get` synchronously anyway doesn't make sense.

</details>
Note that x is inferred as `{foo:number;bar:string}` rather then `{foo:number;bar:string}`.
Note that `{foo:number;bar:string} |{ foo:number } = {foo:number;bar:string}`

</details>

Expand Down

0 comments on commit 5704656

Please sign in to comment.