From 17735afdc31362ecf75c1fcfeb68cd7c42d311f0 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 2 Nov 2022 23:51:22 -0400 Subject: [PATCH] Rewrite `MergeParameters` to work with TS 4.9 Per https://github.com/microsoft/TypeScript/pull/50831 , the existing implementation of `MergeParameters` broke with TS 49 due to a change in how "optional" and "undefined" arguments get interpreted Anders Hjelsberg himself supplied a new implementation that's actually much simpler, but only works with TS 4.7 and greater. Normally this would require shipping two entirely different sets of types, as we already do for TS <4.1. However, there's a trick we used with RTK where we: - Create a folder and put two different files with different impls of the same type inside - Add an index file and re-export one of those - Add a file named `package.dist.json` containing `typesVersions` that points to both of those `.d.ts` files - Copy that `package.dist.json` over to `dist/some/package.json` during the actual build/publish step That way during dev TS just imports the type as normal, but the built version sees that `some/package.json`, sees `typesVersions`, finds the right `.d.ts` file, and imports the right implementation for itself. --- .eslintrc | 2 +- .github/workflows/build-and-test-types.yml | 16 +- package.json | 2 +- src/types.ts | 175 ++------------------- src/versionedTypes/index.ts | 1 + src/versionedTypes/package.dist.json | 14 ++ src/versionedTypes/ts46-mergeParameters.ts | 171 ++++++++++++++++++++ src/versionedTypes/ts47-mergeParameters.ts | 44 ++++++ typescript_test/test.ts | 50 +++--- typescript_test/tsconfig.json | 9 +- 10 files changed, 295 insertions(+), 189 deletions(-) create mode 100644 src/versionedTypes/index.ts create mode 100644 src/versionedTypes/package.dist.json create mode 100644 src/versionedTypes/ts46-mergeParameters.ts create mode 100644 src/versionedTypes/ts47-mergeParameters.ts diff --git a/.eslintrc b/.eslintrc index d1c7292a..d9ad961b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -45,7 +45,7 @@ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-shadow": ["error"], + "@typescript-eslint/no-shadow": ["off"], "@typescript-eslint/no-use-before-define": ["error"], "@typescript-eslint/ban-types": "off", "prefer-rest-params": "off", diff --git a/.github/workflows/build-and-test-types.yml b/.github/workflows/build-and-test-types.yml index 63521db3..4b0b3120 100644 --- a/.github/workflows/build-and-test-types.yml +++ b/.github/workflows/build-and-test-types.yml @@ -62,15 +62,25 @@ jobs: - name: Install deps run: yarn install - - name: Install TypeScript ${{ matrix.ts }} - run: yarn add typescript@${{ matrix.ts }} - + # Build with the actual TS version in the repo - name: Pack run: yarn build && yarn pack - name: Install build artifact run: yarn add ./package.tgz + # Then install the specific version to test against + - name: Install TypeScript ${{ matrix.ts }} + run: yarn add --dev typescript@${{ matrix.ts }} + + - name: 'Remove file that only builds with TS 4.7+' + if: ${{ matrix.ts < 4.7 }} + run: rm src/versionedTypes/ts47-mergeParameters.ts src/versionedTypes/index.ts + + # Remove config line that points "reselect" to the `src` folder, + # so that the typetest will use the installed version instead + - run: sed -i -e /@remap-prod-remove-line/d ./typescript_test/tsconfig.json + - name: Test types run: | ./node_modules/.bin/tsc --version diff --git a/package.json b/package.json index 55f6d0fd..214ca9da 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "scripts": { "build:commonjs": "cross-env BABEL_ENV=commonjs babel src/*.ts --ignore src/types.ts --extensions .ts --out-dir lib ", - "build:es": "babel src/*.ts --ignore src/types.ts --extensions .ts --out-dir es", + "build:es": "babel src/*.ts --ignore src/types.ts --extensions .ts --out-dir es && cp src/versionedTypes/package.dist.json es/versionedTypes/package.json", "build:umd": "cross-env NODE_ENV=development rollup -c -o dist/reselect.js", "build:umd:min": "cross-env NODE_ENV=production rollup -c -o dist/reselect.min.js", "build:types": "tsc", diff --git a/src/types.ts b/src/types.ts index 4690b810..4a016677 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,6 @@ +import type { MergeParameters } from './versionedTypes' +export type { MergeParameters } from './versionedTypes' + /* * * Reselect Data Types @@ -94,56 +97,6 @@ export type GetParamsFromSelectors< RemainingItems extends readonly unknown[] = Tail> > = RemainingItems -/** Given a set of input selectors, extracts the intersected parameters to determine - * what values can actually be passed to all of the input selectors at once - * WARNING: "you are not expected to understand this" :) - */ -export type MergeParameters< - // The actual array of input selectors - T extends readonly UnknownFunction[], - // Given those selectors, we do several transformations on the types in sequence: - // 1) Extract "the type of parameters" for each input selector, so that we now have - // a tuple of all those parameters - ParamsArrays extends readonly any[][] = ExtractParams, - // 2) Transpose the parameter tuples. - // Originally, we have nested arrays with "all params from input", "from input 2", etc: - // `[ [i1a, i1b, i1c], [i2a, i2b, i2c], [i3a, i3b, i3c] ], - // In order to intersect the params at each index, we need to transpose them so that - // we have "all the first args", "all the second args", and so on: - // `[ [i1a, i2a, i3a], [i1b, i2b, i3b], [i1c, i2c, i3c] ] - // Unfortunately, this step also turns the arrays into a union, and weirder, it is - // a union of all possible combinations for all input functions, so there's duplicates. - TransposedArrays = Transpose, - // 3) Turn the union of arrays back into a nested tuple. Order does not matter here. - TuplifiedArrays extends any[] = TuplifyUnion, - // 4) Find the longest params array out of the ones we have. - // Note that this is actually the _nested_ data we wanted out of the transpose step, - // so it has all the right pieces we need. - LongestParamsArray extends readonly any[] = LongestArray -> = - // After all that preparation work, we can actually do parameter extraction. - // These steps work somewhat inside out (jump ahead to the middle): - // 11) Finally, after all that, run a shallow expansion on the values to make the user-visible - // field details more readable when viewing the selector's type in a hover box. - ExpandItems< - // 10) Tuples can have field names attached, and it seems to work better to remove those - RemoveNames<{ - // 5) We know the longest params array has N args. Loop over the indices of that array. - // 6) For each index, do a check to ensure that we're _only_ checking numeric indices, - // not any field names for array functions like `slice()` - [index in keyof LongestParamsArray]: LongestParamsArray[index] extends LongestParamsArray[number] - ? // 9) Any object types that were intersected may have had - IgnoreInvalidIntersections< - // 8) Then, intersect all of the parameters for this arg together. - IntersectAll< - // 7) Since this is a _nested_ array, extract the right sub-array for this index - LongestParamsArray[index] - > - > - : never - }> - > - /* * * Reselect Internal Utility Types @@ -153,28 +106,11 @@ export type MergeParameters< /** Any function with arguments */ export type UnknownFunction = (...args: any[]) => any -/** An object with no fields */ -type EmptyObject = { - [K in any]: never -} - -type IgnoreInvalidIntersections = T extends EmptyObject ? never : T - -/** Extract the parameters from all functions as a tuple */ -export type ExtractParams = { - [index in keyof T]: T[index] extends T[number] ? Parameters : never -} - /** Extract the return type from all functions as a tuple */ export type ExtractReturnType = { [index in keyof T]: T[index] extends T[number] ? ReturnType : never } -/** Recursively expand all fields in an object for easier reading */ -export type ExpandItems = { - [index in keyof T]: T[index] extends T[number] ? Expand : never -} - /** First item in an array */ export type Head = T extends [any, ...any[]] ? T[0] : never /** All other items in an array */ @@ -191,58 +127,6 @@ export type List = ReadonlyArray export type Has = [U1] extends [U] ? 1 : 0 -/** Select the longer of two arrays */ -export type Longest = L extends unknown - ? L1 extends unknown - ? { 0: L1; 1: L }[Has] - : never - : never - -/** Recurse over a nested array to locate the longest one. - * Acts like a type-level `reduce()` - */ -export type LongestArray = - // If this isn't a tuple, all indices are the same, we can't tell a difference - IsTuple extends '0' - ? // so just return the type of the first item - S[0] - : // If there's two nested arrays remaining, compare them - S extends [any[], any[]] - ? Longest - : // If there's more than two, extract their types, treat the remainder as a smaller array - S extends [any[], any[], ...infer Rest] - ? // then compare those two, recurse through the smaller array, and compare vs its result - Longest< - Longest, - Rest extends any[][] ? LongestArray : [] - > - : // If there's one item left, return it - S extends [any[]] - ? S[0] - : never - -/** Recursive type for intersecting together all items in a tuple, to determine - * the final parameter type at a given argument index in the generated selector. */ -export type IntersectAll = IsTuple extends '0' - ? T[0] - : _IntersectAll - -type IfJustNullish = [T] extends [undefined | null] - ? True - : False - -/** Intersect a pair of types together, for use in parameter type calculation. - * This is made much more complex because we need to correctly handle cases - * where a function has fewer parameters and the type is `undefined`, as well as - * optional params or params that have `null` or `undefined` as part of a union. - * - * If the next type by itself is `null` or `undefined`, we exclude it and return - * the other type. Otherwise, intersect them together. - */ -type _IntersectAll = T extends [infer First, ...infer Rest] - ? _IntersectAll> - : R - /* * * External/Copied Utility Types @@ -253,32 +137,22 @@ type _IntersectAll = T extends [infer First, ...infer Rest] * Source: https://github.com/sindresorhus/type-fest/blob/main/source/union-to-intersection.d.ts * Reference: https://github.com/microsoft/TypeScript/issues/29594 */ -export type UnionToIntersection = ( +export type UnionToIntersection = // `extends unknown` is always going to be the case and is used to convert the // `Union` into a [distributive conditional // type](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#distributive-conditional-types). - Union extends unknown - ? // The union type is used as the only argument to a function since the union - // of function arguments is an intersection. - (distributedUnion: Union) => void - : // This won't happen. - never - // Infer the `Intersection` type since TypeScript represents the positional - // arguments of unions of functions as an intersection of the union. -) extends (mergedIntersection: infer Intersection) => void - ? Intersection - : never - -/** - * Removes field names from a tuple - * Source: https://stackoverflow.com/a/63571175/62937 - */ -type RemoveNames = [any, ...T] extends [ - any, - ...infer U -] - ? U - : never + ( + Union extends unknown + ? // The union type is used as the only argument to a function since the union + // of function arguments is an intersection. + (distributedUnion: Union) => void + : // This won't happen. + never + ) extends // Infer the `Intersection` type since TypeScript represents the positional + // arguments of unions of functions as an intersection of the union. + (mergedIntersection: infer Intersection) => void + ? Intersection + : never /** * Assorted util types for type-level conditional logic @@ -313,7 +187,7 @@ type LastOf = UnionToIntersection< : never // TS4.1+ -type TuplifyUnion< +export type TuplifyUnion< T, L = LastOf, N = [T] extends [never] ? true : false @@ -331,21 +205,6 @@ export type ObjValueTuple< ? ObjValueTuple : R -/** - * Transposes nested arrays - * Source: https://stackoverflow.com/a/66303933/62937 - */ -type Transpose = T[Extract< - keyof T, - T extends readonly any[] ? number : unknown ->] extends infer V - ? { - [K in keyof V]: { - [L in keyof T]: K extends keyof T[L] ? T[L][K] : undefined - } - } - : never - /** Utility type to infer the type of "all params of a function except the first", so we can determine what arguments a memoize function accepts */ export type DropFirst = T extends [unknown, ...infer U] ? U diff --git a/src/versionedTypes/index.ts b/src/versionedTypes/index.ts new file mode 100644 index 00000000..b2ff693e --- /dev/null +++ b/src/versionedTypes/index.ts @@ -0,0 +1 @@ +export { MergeParameters } from './ts47-mergeParameters' diff --git a/src/versionedTypes/package.dist.json b/src/versionedTypes/package.dist.json new file mode 100644 index 00000000..600d9d1e --- /dev/null +++ b/src/versionedTypes/package.dist.json @@ -0,0 +1,14 @@ +{ + "typesVersions": { + ">=4.7": { + "index": [ + "./ts47-mergeParameters.d.ts" + ] + }, + "<4.7": { + "index": [ + "./ts46-mergeParameters.d.ts" + ] + } + } +} diff --git a/src/versionedTypes/ts46-mergeParameters.ts b/src/versionedTypes/ts46-mergeParameters.ts new file mode 100644 index 00000000..bf92fbff --- /dev/null +++ b/src/versionedTypes/ts46-mergeParameters.ts @@ -0,0 +1,171 @@ +import type { + UnknownFunction, + Expand, + TuplifyUnion, + Has, + List, + IsTuple +} from '../types' + +/** Given a set of input selectors, extracts the intersected parameters to determine + * what values can actually be passed to all of the input selectors at once + * WARNING: "you are not expected to understand this" :) + */ +export type MergeParameters< + // The actual array of input selectors + T extends readonly UnknownFunction[], + // Given those selectors, we do several transformations on the types in sequence: + // 1) Extract "the type of parameters" for each input selector, so that we now have + // a tuple of all those parameters + ParamsArrays extends readonly any[][] = ExtractParams, + // 2) Transpose the parameter tuples. + // Originally, we have nested arrays with "all params from input", "from input 2", etc: + // `[ [i1a, i1b, i1c], [i2a, i2b, i2c], [i3a, i3b, i3c] ], + // In order to intersect the params at each index, we need to transpose them so that + // we have "all the first args", "all the second args", and so on: + // `[ [i1a, i2a, i3a], [i1b, i2b, i3b], [i1c, i2c, i3c] ] + // Unfortunately, this step also turns the arrays into a union, and weirder, it is + // a union of all possible combinations for all input functions, so there's duplicates. + TransposedArrays = Transpose, + // 3) Turn the union of arrays back into a nested tuple. Order does not matter here. + TuplifiedArrays extends any[] = TuplifyUnion, + // 4) Find the longest params array out of the ones we have. + // Note that this is actually the _nested_ data we wanted out of the transpose step, + // so it has all the right pieces we need. + LongestParamsArray extends readonly any[] = LongestArray +> = + // After all that preparation work, we can actually do parameter extraction. + // These steps work somewhat inside out (jump ahead to the middle): + // 11) Finally, after all that, run a shallow expansion on the values to make the user-visible + // field details more readable when viewing the selector's type in a hover box. + ExpandItems< + // 10) Tuples can have field names attached, and it seems to work better to remove those + RemoveNames<{ + // 5) We know the longest params array has N args. Loop over the indices of that array. + // 6) For each index, do a check to ensure that we're _only_ checking numeric indices, + // not any field names for array functions like `slice()` + [index in keyof LongestParamsArray]: LongestParamsArray[index] extends LongestParamsArray[number] + ? // 9) Any object types that were intersected may have had + IgnoreInvalidIntersections< + // 8) Then, intersect all of the parameters for this arg together. + IntersectAll< + // 7) Since this is a _nested_ array, extract the right sub-array for this index + LongestParamsArray[index] + > + > + : never + }> + > + +/* + * + * Reselect Internal Utility Types + * + */ + +/* + * + * Reselect Internal Utility Types + * + */ + +/** An object with no fields */ +type EmptyObject = { + [K in any]: never +} + +type IgnoreInvalidIntersections = T extends EmptyObject ? never : T + +/** Extract the parameters from all functions as a tuple */ +export type ExtractParams = { + [index in keyof T]: T[index] extends T[number] ? Parameters : never +} + +/** Recursively expand all fields in an object for easier reading */ +export type ExpandItems = { + [index in keyof T]: T[index] extends T[number] ? Expand : never +} + +/** Select the longer of two arrays */ +export type Longest = L extends unknown + ? L1 extends unknown + ? { 0: L1; 1: L }[Has] + : never + : never + +/** Recurse over a nested array to locate the longest one. + * Acts like a type-level `reduce()` + */ +export type LongestArray = + // If this isn't a tuple, all indices are the same, we can't tell a difference + IsTuple extends '0' + ? // so just return the type of the first item + S[0] + : // If there's two nested arrays remaining, compare them + S extends [any[], any[]] + ? Longest + : // If there's more than two, extract their types, treat the remainder as a smaller array + S extends [any[], any[], ...infer Rest] + ? // then compare those two, recurse through the smaller array, and compare vs its result + Longest< + Longest, + Rest extends any[][] ? LongestArray : [] + > + : // If there's one item left, return it + S extends [any[]] + ? S[0] + : never + +/** Recursive type for intersecting together all items in a tuple, to determine + * the final parameter type at a given argument index in the generated selector. */ +export type IntersectAll = IsTuple extends '0' + ? T[0] + : _IntersectAll + +type IfJustNullish = [T] extends [undefined | null] + ? True + : False + +/** Intersect a pair of types together, for use in parameter type calculation. + * This is made much more complex because we need to correctly handle cases + * where a function has fewer parameters and the type is `undefined`, as well as + * optional params or params that have `null` or `undefined` as part of a union. + * + * If the next type by itself is `null` or `undefined`, we exclude it and return + * the other type. Otherwise, intersect them together. + */ +type _IntersectAll = T extends [infer First, ...infer Rest] + ? _IntersectAll> + : R + +/* + * + * External/Copied Utility Types + * + */ + +/** + * Removes field names from a tuple + * Source: https://stackoverflow.com/a/63571175/62937 + */ +type RemoveNames = [any, ...T] extends [ + any, + ...infer U +] + ? U + : never + +/** + * Transposes nested arrays + * Source: https://stackoverflow.com/a/66303933/62937 + */ +type Transpose = T[Extract< + keyof T, + T extends readonly any[] ? number : unknown +>] extends infer V + ? { + [K in keyof V]: { + [L in keyof T]: K extends keyof T[L] ? T[L][K] : undefined + } + } + : never diff --git a/src/versionedTypes/ts47-mergeParameters.ts b/src/versionedTypes/ts47-mergeParameters.ts new file mode 100644 index 00000000..51e19dfa --- /dev/null +++ b/src/versionedTypes/ts47-mergeParameters.ts @@ -0,0 +1,44 @@ +// This entire implementation courtesy of Anders Hjelsberg: +// https://github.com/microsoft/TypeScript/pull/50831#issuecomment-1253830522 + +type UnknownFunction = (...args: any[]) => any + +type LongestTuple = T extends [ + infer U extends unknown[] +] + ? U + : T extends [infer U, ...infer R extends unknown[][]] + ? MostProperties> + : never + +type MostProperties = keyof U extends keyof T ? T : U + +type ElementAt = N extends keyof T + ? T[N] + : unknown + +type ElementsAt = { + [K in keyof T]: ElementAt +} + +type Intersect = T extends [] + ? unknown + : T extends [infer H, ...infer T] + ? H & Intersect + : T[number] + +type MergeTuples< + T extends readonly unknown[][], + L extends unknown[] = LongestTuple +> = { + [K in keyof L]: Intersect> +} + +type ExtractParameters = { + [K in keyof T]: Parameters +} + +export type MergeParameters = + '0' extends keyof T + ? MergeTuples> + : Parameters diff --git a/typescript_test/test.ts b/typescript_test/test.ts index bf7d7e51..9ccafdb5 100644 --- a/typescript_test/test.ts +++ b/typescript_test/test.ts @@ -10,7 +10,7 @@ import { OutputSelector, SelectorResultArray, Selector -} from '../src' +} from 'reselect' import microMemoize from 'micro-memoize' import memoizeOne from 'memoize-one' @@ -587,7 +587,7 @@ function testOptionalArgumentsConflicting() { (state: State) => state.baz, (state: State, arg: string) => arg, (state: State, arg: number) => arg, - (baz) => { + baz => { const baz1: boolean = baz // @ts-expect-error const baz2: number = baz @@ -603,7 +603,7 @@ function testOptionalArgumentsConflicting() { const selector2 = createSelector( (state: State, prefix: any) => prefix + state.foo, - (str) => str + str => str ) // @ts-expect-error here we require one argument which can be anything so error if there are no arguments @@ -615,7 +615,7 @@ function testOptionalArgumentsConflicting() { // here the argument is optional so it should be possible to omit the argument or pass anything const selector3 = createSelector( (state: State, prefix?: any) => prefix + state.foo, - (str) => str + str => str ) selector3({} as State) @@ -624,8 +624,9 @@ function testOptionalArgumentsConflicting() { // https://github.com/reduxjs/reselect/issues/563 const selector4 = createSelector( - (state: State, prefix: string, suffix: any) => prefix + state.foo + String(suffix), - (str) => str + (state: State, prefix: string, suffix: any) => + prefix + state.foo + String(suffix), + str => str ) // @ts-expect-error @@ -636,8 +637,9 @@ function testOptionalArgumentsConflicting() { // as above but a unknown 2nd argument const selector5 = createSelector( - (state: State, prefix: string, suffix: unknown) => prefix + state.foo + String(suffix), - (str) => str + (state: State, prefix: string, suffix: unknown) => + prefix + state.foo + String(suffix), + str => str ) // @ts-expect-error @@ -646,21 +648,21 @@ function testOptionalArgumentsConflicting() { selector5({} as State, 'blach') selector5({} as State, 'blach', 4) - // @ts-expect-error It would be great to delete this, it is not correct. - // Due to what must be a TS bug? if the default parameter is used, we lose the type for prefix - // and it is impossible to type the selector without typing prefix - const selector6 = createSelector( - (state: State, prefix = '') => prefix + state.foo, - (str: string) => str - ) - - // because the suppressed error above, selector6 has broken typings and doesn't allow a passed parameter - selector6({} as State) - // @ts-expect-error would be great if we can delete this, it should not error - selector6({} as State, 'blach') - // @ts-expect-error wrong type - selector6({} as State, 1) - + // This next section is now obsolete with the changes in TS 4.9 + // // @ts-expect-error It would be great to delete this, it is not correct. + // // Due to what must be a TS bug? if the default parameter is used, we lose the type for prefix + // // and it is impossible to type the selector without typing prefix + // const selector6 = createSelector( + // (state: State, prefix = '') => prefix + state.foo, + // (str: string) => str + // ) + + // // because the suppressed error above, selector6 has broken typings and doesn't allow a passed parameter + // selector6({} as State) + // // @ts-expect-error would be great if we can delete this, it should not error + // selector6({} as State, 'blach') + // // @ts-expect-error wrong type + // selector6({} as State, 1) // this is an example fixing selector6. We have to add a un-necessary typing in and magically the types are correct const selector7 = createSelector( @@ -679,7 +681,7 @@ function testOptionalArgumentsConflicting() { const selector8 = createSelector( (state: State, prefix: unknown) => prefix, - (str) => str + str => str ) // @ts-expect-error needs a argument diff --git a/typescript_test/tsconfig.json b/typescript_test/tsconfig.json index 1c4f4e58..268d2823 100644 --- a/typescript_test/tsconfig.json +++ b/typescript_test/tsconfig.json @@ -4,7 +4,12 @@ "strict": true, "target": "ES2015", "declaration": true, - "noEmit": true + "noEmit": true, + "skipLibCheck": true, + "paths": { + "reselect": ["../src/index"] // @remap-prod-remove-line + } }, - "include": ["test.ts"] + "include": ["test.ts"], + }