Skip to content

Rewrite TakeTwo, SkipTwo and Mutate to make them work for older ts versions too #1348

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

Merged
merged 19 commits into from
Oct 9, 2022

Conversation

devanshj
Copy link
Contributor

@devanshj devanshj commented Oct 7, 2022

Fixes #1013. Superceeds #1275.

What is the issue?

The core of the issue is this: TypeScript changed subtyping rules of tuples with optional elements (probably) in v4.4.4. The type Test in the following snippet resolves to "yes" in v4.3.5 and before but it resolves to "no" in 4.4.4 and after.

type Test = [string, number?] extends [unknown] ? "yes" : "no"

This should most probably be intentional as the change is so old and 4.4.4 also happens to be the version that came with --exactOptionalProperties so it's possible it touched upon tuples with optional element. It's also possible that this is a "fix" or that it increases the "strictness" of the subtyping rules. (Also turning --exactOptionalProperties off or on does not have any effect on the subtyping.)

Now how does it affect us? We have a type TakeTwo<T> that does a type-level equivalent of...

const takeTwo = _x => {
  let y = [], x = [..._x];
  while (y.length < 2) y.push(x.shift())
  return y
}

// This is similar to `.slice(0, 2)` except we fill the remaining spaces with `undefined`s
// so that the final result is of length 2.

Now the problem is TakeTwo doesn't work for pre-4.4.4 version...

type TakeTwo<T> =
  T extends [] ? [undefined, undefined] :
  T extends [unknown] ? [...a0: T, a1: undefined] :
  T extends [unknown?] ? [...a0: T, a1: undefined] :
  T extends [unknown, unknown] ? T :
  T extends [unknown, unknown?] ? T :
  T extends [unknown?, unknown?] ? T :
  T extends [infer A0, infer A1, ...unknown[]] ? [A0, A1] :
  T extends [infer A0, (infer A1)?, ...unknown[]] ? [A0, A1?] :
  T extends [(infer A0)?, (infer A1)?, ...unknown[]] ? [A0?, A1?] :
  never

type Test = TakeTwo<[state: number, replace?: boolean]>
// Test is [state: number, replace: boolean | undefined, a1: undefined] <4.4.4
// and [state: number, replace: boolean | undefined] >= 4.4.4

So now even though our code is "correct" it doesn't work with pre-4.4.4. Same is the case for SkipTwo that does a type-level .slice(2).

How do we solve it?

Now in some sense we can't write a code that works for both versions because typescript changed the math itself.

Imagine you have a function f as const f = (a, b) => a <> b. Now in wild-land-4.3 <> did subtraction, but now in wild-lang-4.4 <> does addition. Now you can't write a function f that does addition in both wild-lang-4.3 and wild-land-4.4.

So if we want to write TakeTwo that works for both pre-change and post-change versions, we'll have to use only those rules that are same in both, some subtyping rules for tuples with optional element are not same so we can't use them.

What is consistent in both is the length property, eg [string, number?]["length"] is 1 | 2 for both versions. So we can compare the lengths instead of comparing the tuples themselves...

type Test = [string, number?] extends { length: 1 | 2 }  ? "yes" : "no"
// Test is "yes" in <4.4.4 and >=4.4.4

So the new TakeTwo looks like this...

type TakeTwo<T> =
  T extends { length: 0 } ? [undefined, undefined] :
  T extends { length: 1 } ? [...a0: Cast<T, unknown[]>, a1: undefined] :
  T extends { length: 0 | 1 } ? [...a0: Cast<T, unknown[]>, a1: undefined] :
  T extends { length: 2 } ? T :
  T extends { length: 1 | 2 } ? T :
  T extends { length: 0 | 1 | 2 } ? T :
  T extends [infer A0, infer A1, ...unknown[]] ? [A0, A1] :
  T extends [infer A0, (infer A1)?, ...unknown[]] ? [A0, A1?] :
  T extends [(infer A0)?, (infer A1)?, ...unknown[]] ? [A0?, A1?] :
  never

type Cast<T, U> =
  T extends U ? T : U

type Test = TakeTwo<[state: number, replace?: boolean]>
// Test is [state: number, replace?: boolean] in both <4.4.4 and >= 4.4.4

(The last three cases remain the same as the subtyping rule is consistent for them, and nor there's another way of writing them.)

And we make a similar change for SkipTwo also.

How do we test it?

Now usually libraries only target the latest typescript version, or whatever version the repo is running on, but here we have to target an old version.

This usualy would have been simple and we could test it with a script like so...

tsc --noEmit # type-check with current ts version
./node_modules/typescript-4.3.5/bin/tsc --noEmit # type-check with old ts version

But now the problem is the version 4.3.5 is so old that there would be some current tests that would be failing because we're using new features, eg --exactOptionalProperties. So now those type-check errors are expected, so we can't just rely on the exit code. What we'll have to do see if the tests type-check except some errors that we expect.

To do this I'm using inline snapshots. That is the oldTsc --noEmit output should only contain type errors we expect.

To confirm that this change in TakeTwo and SkipTwo fixes the problem, see the snapshot in first commit, it has 38 errors, but now after we make this change, the new snapshot in the second commit only has 6 expected errors that we can't fix because it's an older tsc that doesn't support the new features and changes.

...to work with both pre 4.3.5 and post 4.3.5 subtyping rules.

The type `Test` in the following snippet resolves to `"yes"` for
<=4.3.5 but `"no"` for >4.3.5
```ts
type Test = [string, number?] extends [unknown] ? "yes" : "no"
```
(note 4.3.5 is bisected via typescript playground which skips
some versions so it might not be super accurate, but it's
accurate enough)
@codesandbox-ci
Copy link

codesandbox-ci bot commented Oct 7, 2022

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 0997ee5:

Sandbox Source
React Configuration
React Typescript Configuration
React Browserify Configuration
React Snowpack Configuration
React Parcel Configuration
Next.js Configuration

@devanshj
Copy link
Contributor Author

devanshj commented Oct 7, 2022

Also having two typescript packages somehow makes the tsc binary point to 4.3.5 instead of 4.7.4. I'm not sure if there's a way to tell yarn to not install the binary for 4.3.5 version. As a fix I've added a tsc script that points to the correct binary...

+ "tsc": "./node_modules/typescript/bin/tsc",
- "pretest": "tsc --noEmit",
+ "pretest": "yarn tsc --noEmit",

Let me know if there's a better way.

Also if you want to remove the this test and testing strategy then I'm fine with that too, but I think it's nice to have it.

Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

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

Wow, nice hack. I have a different testing strategy which only works on workflows. Let me try.

@devanshj
Copy link
Contributor Author

devanshj commented Oct 8, 2022

Wow, nice hack.

Haha thanks.

I have a different testing strategy which only works on workflows. Let me try.

Sure.

If you want that test to run only on ci, then I think there are several ways to do, probably the simplest way would be to use --testPathIgnorePatterns ci-only and rename issue1013.test.tsx to issue1013.ci-only.test.tsx...

  "test": "jest",
  "test:ci": "jest",
- "test:dev": "jest --watch --no-coverage",
+ "test:dev": "jest --watch --no-coverage --testPathIgnorePatterns ci-only",

@devanshj
Copy link
Contributor Author

devanshj commented Oct 8, 2022

2a28f8e add a workflow to test with old tsc

Ha interesting. But unfortunately this won't work because ther's an error in the source too...

"src/vanilla.ts:104:17 - error TS2590: Expression produces a union type that is too complex to represent.
104   createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If you want to test with this approach you'll either have to add an redundant // @ts-ignore on this line in the source, which seems like a really bad idea, or add a comment like // add-it-ignore-in-ci in the source and then in ci replace that comment with // @ts-ignore but then you'll have to find and replace in all files which is also not a great idea I guess.

I actually like my testing strategy more, we're only testing what we specifically want to test and support. This is a bit overboard as in we'll have to keep in mind old versions too while authoring. But again I'm fine with whatever.

@dai-shi
Copy link
Member

dai-shi commented Oct 8, 2022

Ha interesting. But unfortunately this won't work because ther's an error in the source too...

"src/vanilla.ts:104:17 - error TS2590: Expression produces a union type that is too complex to represent.
104   createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Yeah, I notice that too. But, then it means that this PR doesn't fix for older ts versions, doesn't it?

@devanshj
Copy link
Contributor Author

devanshj commented Oct 8, 2022

Yeah, I notice that too. But, then it means that this PR doesn't fix for older ts versions, doesn't it?

Kinda yes, but that type error won't happen for the user as the user only has zustand.d.ts which only has the type not the implementation. So this PR does fix for older ts versions as the user simply won't see or have that type error.

@dai-shi
Copy link
Member

dai-shi commented Oct 8, 2022

we'll have to keep in mind old versions too while authoring.

That's my intention actually.

@devanshj
Copy link
Contributor Author

devanshj commented Oct 8, 2022

That's my intention actually.

Ha, well if you want to take supporting older versions seriously then it's not a bad idea :P

@dai-shi
Copy link
Member

dai-shi commented Oct 8, 2022

So, it seems like our lowest supported ts version is v4.1.5.

@devanshj Do you know how to fix the issue in v4.4.4?

dai-shi and others added 2 commits October 8, 2022 11:38
...wrt to the domain `StoreMutatorIdentifier[]` to help the type-checker
@devanshj
Copy link
Contributor Author

devanshj commented Oct 8, 2022

fbb2a14 make Mutate more complete wrt to the domain StoreMutatorIdentifier[] to help the type-checker

Let me explain this a bit, imagine we're writing this code...

const join =
  <L extends string[], D extends string>
  (l: L, d: D) =>
    l.join(d) as Join<L, D>

type Join<L extends string[], D extends string> = 
  L extends []
    ? "" :
  L extends [infer H extends string]
    ? H :
  L extends [infer H extends string, ...infer T extends string[]]
    ? `${H}${D}${Join<T, D>}` :
  never

let x = join(["a", "b"] as ["a", "b"], " | ")
// typeof x is "a | b"

Often when we write types like Join we forget that the domain is string[], which also includes the non-tuple arrays...

type Test0 = Join<["a", "b"], " | ">
// "a | b"

type Test1 = Join<"a"[], " | ">
// never, should be string

type Test2 = Join<string[], " | ">
// never, should be string

The last two arguments are indeed in the domain string[] but they have no been handled in the implementation which results in bad results like never. This also affects the function join of course...

let x: "a"[] = ["a", "a"]
let test1 = join(x, " | ")
// typeof test1 is never, should be string

let y: string[] = ["a", "b"]
let test2 = join(y, " | ")
// typeof test2 is never, should be string

So a more complete version of Join would be this...

type Join<L extends string[], D extends string> =
  number extends L["length"]
    ? string :
  L extends []
    ? "" :
  L extends [infer H extends string]
    ? H :
  L extends [infer H extends string, ...infer T extends string[]]
    ? `${H}${D}${Join<T, D>}` :
  never

Now the result will always be string even for arrays.

Now one might ask the question... But what if we never pass arrays to Join? And that indeed is the case with Mutate, we always pass tuples, never arrays, so why this change matters and fixes the issue?

The thing is even if we don't pass tuples, typescript does... How? Let's look at the following code...

const join =
  <L extends string[], D extends string>
  (l: L, d: D) =>
    l.join(d) as Join<L, D>

const identitiyString =
  <T extends string>(t: T) => t

const join2 =
  <L extends string[], D extends string>
  (l: L, d: D) =>
    identitiyString(join(l, d))

type Join<L extends string[], D extends string> = 
  L extends []
    ? "" :
  L extends [infer H extends string]
    ? H :
  L extends [infer H extends string, ...infer T extends string[]]
    ? `${H}${D}${Join<T, D>}` :
  number

let x = join2(["a", "b"] as ["a", "b"], " | "))
// typeof x is "a | b"

Here one would assume that the only thing that ever gets passed to Join is ["a", "b"], but that's incorrect. The easiest way to check it is by replacing never with number. Surely if only ["a", "b"] is the only thing passed to Join we'd never reach that last case and it shouldn't matter what we write there... Let's see what happens...

const join =
  <L extends string[], D extends string>
  (l: L, d: D) =>
    l.join(d) as Join<L, D>

const identitiyString =
  <T extends string>(t: T) => t

const join2 =
  <L extends string[], D extends string>
  (l: L, d: D) =>
    identitiyString(join(l, d))
//                  ~~~~~~~~~~[1]

type Join<L extends string[], D extends string> = 
  L extends []
    ? "" :
  L extends [infer H extends string]
    ? H :
  L extends [infer H extends string, ...infer T extends string[]]
    ? `${H}${D}${Join<T, D>}` :
  number

let x = identitiyString(join(["a", "b"] as ["a", "b"], " | "))
// typeof x is "a | b"

/* 
[1]:
Argument of type 'Join<L, D>' is not assignable to parameter of type 'string'.
  Type 'string | (L extends [infer H extends string, ...infer T extends string[]] ? `${H}${D}${Join<T, D>}` : number)' is not assignable to type 'string'.
    Type 'L extends [infer H extends string, ...infer T extends string[]] ? `${H}${D}${Join<T, D>}` : number' is not assignable to type 'string'.
      Type 'number | `${string}${D}${number}`' is not assignable to type 'string'.
        Type 'number' is not assignable to type 'string'.
          Type 'number' is not assignable to type 'string'.
            Type 'number' is not assignable to type 'string'.
*/

Ah ha, the fact that it reached to number implies that our Join was indeed passed a non-tuple... The thing is even if we ourselves never pass non-tuple, typescript has to type-check our code... while it was type-checking join2, it saw that join(l, d) must be a string because it's passed to identityString that only accepts string, so now it calculates what would be the type of join(l, d), the type of l is extends string[] and the type of d is extends string, so the type of join(l, d) would be extends Join<extends string[], extends string> and here string[] gets passed to Join, which we never expected.

So same happens with our Mutate, and hence we were getting this error...

"src/vanilla.ts:104:17 - error TS2590: Expression produces a union type that is too complex to represent.
104   createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

(This is the same error in the previous CI in context.test.tsx, typescript seems to report this error only once if the origin is same, because we tested on the build there was no implementation where typescript could encounter it, so the first time it encountered it as in context.test.tsx, but it's the same error.)

Here while type-checking our code, it must have found the need to find out what Mutate<S, Mos> resolves to, so it passed the constraint of Mos ie [StoreMutatorIdentifier, unknown][] to Mutate and because we didn't handle non-tuples its result was never and this probably somehow created a huge union type and our code didn't type-check.

The code type-checks in the newer version probably because the compiler must have gotten smarter and either didn't had the need to pass [StoreMutatorIdentifier, unknown][] to Mutate or even if it did pass, it could figure things our even with a never.

So to help the type-checker we make this change to Mutate...

  export type Mutate<S, Ms> =
+   number extends Ms['length' & keyof Ms] ? S :
+   // don't mutate if we get an array, it's just to aid the type-checker
+   // and except that it'll never happen
    Ms extends [] ? S :
    Ms extends [[infer Mi, infer Ma], ...infer Mrs]
      ? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs> :
    never

Now Mutate is more complete as it is now implemented for the whole domain [StoreMutatorIdentifier, unknown][] (tuples and non-tuples) instead of just a part of the domain (non-tuples).

@devanshj devanshj changed the title Rewrite TakeTwo and SkipTwo to make them work for older ts versions too Rewrite TakeTwo, SkipTwo and Mutate to make them work for older ts versions too Oct 8, 2022
@dai-shi
Copy link
Member

dai-shi commented Oct 9, 2022

Wow, what "a bit". Thanks for the fix. This is great!

@dai-shi dai-shi added this to the v4.1.2 milestone Oct 9, 2022
Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

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

Let's ship it.

@dai-shi dai-shi merged commit 93b5a43 into pmndrs:main Oct 9, 2022
@devanshj
Copy link
Contributor Author

devanshj commented Oct 9, 2022

Wow, what "a bit".

Hahahah, little did I know when I started writing the explanation, how much of "a bit" it'll be xD

Thanks for the fix. This is great!

Nps and thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

TS issue with the set function when using devtools (with older TS versions)
2 participants