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

Rewrite the documentation about why the typescript types can not be inferred #2900

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 26 additions & 37 deletions docs/guides/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,65 +26,54 @@ 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
declare const create: <T>(f: (get: () => T) => T) => T

const x = create((get) => ({
const x = create(() => ({
foo: 0,
bar: () => get(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is required to reproduce the problem. Otherwise, it would have been easier.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bring back bar: () => get() and add some text about how the ts behavior related to real-world scenario

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')
declare const create: <T>(f: (get: () => T) => T) => T
const x = create((get) => ({
foo: 0,
bar: '',
}))
// const x: unknown
```

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

<details>
<summary>More about the inference (just for the people curious and interested in TypeScript)</summary>
`x` is inferred as `unknown`, why the difference? Let us walk through how TS Engine binds the type.

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(/* ? */)
```
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`

`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`.
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`.

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.

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:
If we provide some hints for the parameter type, it works again

```ts
import { create } from 'zustand'

const useBoundStore = create<{ foo: number }>()((_, get) => ({
foo: get().foo,
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