From df5cd55cdb2ebf620fec4df587cc72fb9b8c6509 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 26 Jun 2025 03:05:27 -0700 Subject: [PATCH] Flatten built-in utility types in the API snapshot (#52280) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/52280 Changelog: [Internal] Adds a type-simplifyng transform for the API snapshot, with the goal of resolving some built-in TS types during build time. Most notably, it's able to simplify `Omit` structures emitted by the `flow-api-translator` when translating Flow's type spread operator. It builds upon a simplified type inlining transform from the previous approach. The type inlining transform is able to handle inlining type references and resolution of built-in TS types on literal types: - `Omit` - `Readonly` - `Partial` - `keyof` Reference inlining is performed top-down and built-in type resolution is performed bottom-up, which makes it possible for the second step to assume working on type literals. Type simplifying transform uses the type inlining to reduce type references encountered inside `Omits` to their literal shapes, which makes possible to determine whether `Omit` is neccessary case-by-case. If `Omit` is redundant, it can be safely removed. If it's not, the omitted keys can be reduced to represent a subset of keys existing in the target type. It also keeps the ability to resolve `Partial` and `Readonly` types on type literals, simplifying the snapshot further. An example diff the transform can handle: Before: ``` export declare type AccessibilityProps = Readonly< Omit< AccessibilityPropsAndroid, | keyof { accessibilityActions?: ReadonlyArray accessibilityHint?: string accessibilityLabel?: string accessibilityRole?: AccessibilityRole accessibilityState?: AccessibilityState accessibilityValue?: AccessibilityValue accessible?: boolean "aria-busy"?: boolean "aria-checked"?: "mixed" | (boolean | undefined) "aria-disabled"?: boolean "aria-expanded"?: boolean "aria-hidden"?: boolean "aria-label"?: string "aria-selected"?: boolean "aria-valuemax"?: AccessibilityValue["max"] "aria-valuemin"?: AccessibilityValue["min"] "aria-valuenow"?: AccessibilityValue["now"] "aria-valuetext"?: AccessibilityValue["text"] role?: Role } | keyof AccessibilityPropsIOS > & Omit< AccessibilityPropsIOS, keyof { accessibilityActions?: ReadonlyArray accessibilityHint?: string accessibilityLabel?: string accessibilityRole?: AccessibilityRole accessibilityState?: AccessibilityState accessibilityValue?: AccessibilityValue accessible?: boolean "aria-busy"?: boolean "aria-checked"?: "mixed" | (boolean | undefined) "aria-disabled"?: boolean "aria-expanded"?: boolean "aria-hidden"?: boolean "aria-label"?: string "aria-selected"?: boolean "aria-valuemax"?: AccessibilityValue["max"] "aria-valuemin"?: AccessibilityValue["min"] "aria-valuenow"?: AccessibilityValue["now"] "aria-valuetext"?: AccessibilityValue["text"] role?: Role } > & { accessibilityActions?: ReadonlyArray accessibilityHint?: string accessibilityLabel?: string accessibilityRole?: AccessibilityRole accessibilityState?: AccessibilityState accessibilityValue?: AccessibilityValue accessible?: boolean "aria-busy"?: boolean "aria-checked"?: "mixed" | (boolean | undefined) "aria-disabled"?: boolean "aria-expanded"?: boolean "aria-hidden"?: boolean "aria-label"?: string "aria-selected"?: boolean "aria-valuemax"?: AccessibilityValue["max"] "aria-valuemin"?: AccessibilityValue["min"] "aria-valuenow"?: AccessibilityValue["now"] "aria-valuetext"?: AccessibilityValue["text"] role?: Role } > ``` After: ``` export declare type AccessibilityProps = Readonly< AccessibilityPropsAndroid & AccessibilityPropsIOS & { accessibilityActions?: ReadonlyArray accessibilityHint?: string accessibilityLabel?: string accessibilityRole?: AccessibilityRole accessibilityState?: AccessibilityState accessibilityValue?: AccessibilityValue accessible?: boolean "aria-busy"?: boolean "aria-checked"?: "mixed" | (boolean | undefined) "aria-disabled"?: boolean "aria-expanded"?: boolean "aria-hidden"?: boolean "aria-label"?: string "aria-selected"?: boolean "aria-valuemax"?: AccessibilityValue["max"] "aria-valuemin"?: AccessibilityValue["min"] "aria-valuenow"?: AccessibilityValue["now"] "aria-valuetext"?: AccessibilityValue["text"] role?: Role } > ``` Reviewed By: huntie Differential Revision: D77295302 fbshipit-source-id: 213aef46035bde4f9783353b5344a6986a418399 --- scripts/build-types/BuildApiSnapshot.js | 1 + .../__tests__/inlineTypesVisitor-test.js | 416 ++++++++++++++++++ .../__tests__/simplifyTypes-test.js | 261 +++++++++++ .../simplifyTypes/alignTypeParameters.js | 91 ++++ .../typescript/simplifyTypes/contextStack.js | 136 ++++++ .../simplifyTypes/gatherTypeAliasesVisitor.js | 30 ++ .../typescript/simplifyTypes/index.js | 68 +++ .../typescript/simplifyTypes/inlineType.js | 43 ++ .../simplifyTypes/inlineTypesVisitor.js | 256 +++++++++++ .../simplifyTypes/resolveBuiltinType.js | 249 +++++++++++ .../simplifyTypes/resolveIntersection.js | 159 +++++++ .../typescript/simplifyTypes/resolveTSType.js | 79 ++++ .../simplifyTypes/resolveTypeOperator.js | 83 ++++ .../typescript/simplifyTypes/utils.js | 73 +++ .../typescript/simplifyTypes/visitorState.js | 32 ++ 15 files changed, 1977 insertions(+) create mode 100644 scripts/build-types/transforms/typescript/__tests__/inlineTypesVisitor-test.js create mode 100644 scripts/build-types/transforms/typescript/__tests__/simplifyTypes-test.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/alignTypeParameters.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/contextStack.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/gatherTypeAliasesVisitor.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/index.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/inlineType.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/inlineTypesVisitor.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/resolveBuiltinType.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/resolveIntersection.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/resolveTSType.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/resolveTypeOperator.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/utils.js create mode 100644 scripts/build-types/transforms/typescript/simplifyTypes/visitorState.js diff --git a/scripts/build-types/BuildApiSnapshot.js b/scripts/build-types/BuildApiSnapshot.js index 429b8520658..503d2cbc045 100644 --- a/scripts/build-types/BuildApiSnapshot.js +++ b/scripts/build-types/BuildApiSnapshot.js @@ -36,6 +36,7 @@ const inputFilesPostTransforms: $ReadOnlyArray> = [ ]; const postTransforms: $ReadOnlyArray> = [ + require('./transforms/typescript/simplifyTypes'), require('./transforms/typescript/sortProperties'), require('./transforms/typescript/sortUnions'), require('./transforms/typescript/removeUndefinedFromOptionalMembers'), diff --git a/scripts/build-types/transforms/typescript/__tests__/inlineTypesVisitor-test.js b/scripts/build-types/transforms/typescript/__tests__/inlineTypesVisitor-test.js new file mode 100644 index 00000000000..8253408aa3c --- /dev/null +++ b/scripts/build-types/transforms/typescript/__tests__/inlineTypesVisitor-test.js @@ -0,0 +1,416 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const babel = require('@babel/core'); + +const postTransforms = [require('../simplifyTypes/inlineTypesVisitor')]; + +async function applyPostTransforms(inSrc: string): Promise { + const result = await babel.transformAsync(inSrc, { + plugins: ['@babel/plugin-syntax-typescript', ...postTransforms], + }); + + return result.code; +} + +describe('inlineTypesVisitor', () => { + test('should inline type non-generic type aliases', async () => { + const code = ` + type AnimatedNodeConfig = { + readonly debugID?: string | undefined; + }; + + export type Example = Omit< + AnimatedNodeConfig, + keyof { + useNativeDriver: boolean; + } + > & { + useNativeDriver: boolean; + }; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type AnimatedNodeConfig = { + readonly debugID?: string | undefined; + }; + export type Example = { + readonly debugID?: string | undefined; + useNativeDriver: boolean; + };" + `); + }); + + test('should skip recursive definitions', async () => { + const code = ` + export type LinkedNode = { + value: number; + next: LinkedNode; + }; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "export type LinkedNode = { + value: number; + next: LinkedNode; + };" + `); + }); + + test('should skip co-recursive definitions', async () => { + const code = ` + type Expr = Add | Multiply | number; + + export type Add = { + type: 'add'; + lhs: Expr; + rhs: Expr; + }; + + export type Multiply = { + type: 'multiply'; + lhs: Expr; + rhs: Expr; + }; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Expr = Add | Multiply | number; + export type Add = { + type: 'add'; + lhs: Add | Multiply | number; + rhs: Expr; + }; + export type Multiply = { + type: 'multiply'; + lhs: Expr; + rhs: Expr; + };" + `); + }); + + test('keyof {} is never', async () => { + const code = ` + export type NeverInDisguise = keyof {}; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot( + `"export type NeverInDisguise = never;"`, + ); + }); + + test('keyof for objects with computed string properties', async () => { + const code = ` + export type Foo = keyof { 'a-key': number, 'b-key': number }; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot( + `"export type Foo = \\"a-key\\" | \\"b-key\\";"`, + ); + }); + + test('resolves Readonly type', async () => { + const code = ` + export type Foo = Readonly<{ a?: number, 'b-key': number }>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "export type Foo = { + readonly a?: number; + readonly 'b-key': number; + };" + `); + }); + + test('resolves Partial type', async () => { + const code = ` + export type Foo = Partial<{ a: number, 'b-key': number }>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "export type Foo = { + a?: number; + 'b-key'?: number; + };" + `); + }); + + test('resolves simple intersection', async () => { + const code = ` + export type Foo = { a?: number, 'b-key': number } & { c: number, 'd-key'?: number }; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "export type Foo = { + a?: number; + \\"b-key\\": number; + c: number; + \\"d-key\\"?: number; + };" + `); + }); + + test('can inline multiple references to a single type in a declaration', async () => { + const code = ` + export declare type Result = + & Omit<{ alpha: 1; }, keyof Beta> + & Omit + ; + + declare type Beta = Readonly<{ + beta: 2; + }>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "export declare type Result = { + alpha: 1; + readonly beta: 2; + }; + declare type Beta = { + readonly beta: 2; + };" + `); + }); + + test('should preserve dec comments when inlining types', async () => { + const code = ` + declare type A = { + /** + * Comment for prop1 in A + */ + prop1: string; + /** + * Comment for prop2 in A + */ + prop2: number; + }; + + declare type B = { + /** + * Comment for prop1 in B + */ + prop1: string[]; + /** + * Comment for prop3 in B + */ + prop3: boolean; + }; + + declare type C = { + /** + * Comment for prop4 in D + */ + prop4: number; + }; + + export declare type D = Omit & B, keyof C> & C; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "declare type A = { + /** + * Comment for prop1 in A + */ + prop1: string; + /** + * Comment for prop2 in A + */ + prop2: number; + }; + declare type B = { + /** + * Comment for prop1 in B + */ + prop1: string[]; + /** + * Comment for prop3 in B + */ + prop3: boolean; + }; + declare type C = { + /** + * Comment for prop4 in D + */ + prop4: number; + }; + export declare type D = { + /** + * Comment for prop2 in A + */ + prop2: number; + /** + * Comment for prop1 in B + */ + prop1: string[]; + /** + * Comment for prop3 in B + */ + prop3: boolean; + /** + * Comment for prop4 in D + */ + prop4: number; + };" + `); + }); + + test('should inline generic type aliases', async () => { + const code = ` + type Required = { + data: T[]; + } & { + title: U; + } + + type Optional = { + header: T; + } + + export type Example = Omit< + Required, + keyof Optional + > & Optional; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Required = { + data: T[]; + title: U; + }; + type Optional = { + header: T; + }; + export type Example = { + data: DataT[]; + title: string; + header: DataT; + };" + `); + }); + + test('should inline generic type equal to one of its type parameters', async () => { + const code = ` + type PropsAlias = Props; + export type Example = PropsAlias<{a: string}>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type PropsAlias = Props; + export type Example = { + a: string; + };" + `); + }); + + test('should not confuse local symbols with global ones', async () => { + const code = ` + type PropsAlias = Props; + + type Props = { + b: number; + }; + + export type Example = PropsAlias<{a: string}>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type PropsAlias = Props; + type Props = { + b: number; + }; + export type Example = { + a: string; + };" + `); + }); + + test('should inline types inside Omit when used in union', async () => { + const code = ` + declare type ProgressBarAndroidBaseProps = { + readonly animating?: boolean | undefined; + readonly color?: ColorValue | undefined; + readonly testID?: string | undefined; + }; + + declare type DeterminateProgressBarAndroidStyleAttrProp = { + styleAttr: "Horizontal"; + indeterminate: false; + progress: number; + }; + + declare type IndeterminateProgressBarAndroidStyleAttrProp = { + styleAttr: "Normal" + indeterminate: true; + }; + + export declare type ProgressBarAndroidProps = + | Readonly< + Omit< + ProgressBarAndroidBaseProps, + "styleAttr" | "indeterminate" | "progress" + > & + Omit & {} + > + | Readonly< + Omit< + ProgressBarAndroidBaseProps, + "styleAttr" | "indeterminate" + > & + Omit & {} + >; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "declare type ProgressBarAndroidBaseProps = { + readonly animating?: boolean | undefined; + readonly color?: ColorValue | undefined; + readonly testID?: string | undefined; + }; + declare type DeterminateProgressBarAndroidStyleAttrProp = { + styleAttr: \\"Horizontal\\"; + indeterminate: false; + progress: number; + }; + declare type IndeterminateProgressBarAndroidStyleAttrProp = { + styleAttr: \\"Normal\\"; + indeterminate: true; + }; + export declare type ProgressBarAndroidProps = { + readonly animating?: boolean | undefined; + readonly color?: ColorValue | undefined; + readonly testID?: string | undefined; + readonly styleAttr: \\"Horizontal\\"; + readonly indeterminate: false; + readonly progress: number; + } | { + readonly animating?: boolean | undefined; + readonly color?: ColorValue | undefined; + readonly testID?: string | undefined; + readonly styleAttr: \\"Normal\\"; + readonly indeterminate: true; + };" + `); + }); +}); diff --git a/scripts/build-types/transforms/typescript/__tests__/simplifyTypes-test.js b/scripts/build-types/transforms/typescript/__tests__/simplifyTypes-test.js new file mode 100644 index 00000000000..6787d025feb --- /dev/null +++ b/scripts/build-types/transforms/typescript/__tests__/simplifyTypes-test.js @@ -0,0 +1,261 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const babel = require('@babel/core'); + +const postTransforms = [require('../simplifyTypes')]; + +async function applyPostTransforms(inSrc: string): Promise { + const result = await babel.transformAsync(inSrc, { + plugins: ['@babel/plugin-syntax-typescript', ...postTransforms], + }); + + return result.code; +} + +describe('simplifyTypes', () => { + test('should resolve Readonly on a type literal', async () => { + const code = ` + type Baz = Readonly<{ + foo: string; + }>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Baz = { + readonly foo: string; + };" + `); + }); + + test('should keep Readonly on a type reference', async () => { + const code = ` + type Foo = { + foo: string; + }; + + type Baz = Readonly; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + }; + type Baz = Readonly;" + `); + }); + + test('should resolve Partial on a type literal', async () => { + const code = ` + type Baz = Partial<{ + foo: string; + }>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Baz = { + foo?: string; + };" + `); + }); + + test('should keep Partial on a type reference', async () => { + const code = ` + type Foo = { + foo: string; + }; + + type Baz = Partial; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + }; + type Baz = Partial;" + `); + }); + + test('should resolve nested utility types on a type literal', async () => { + const code = ` + type Baz = Readonly>; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Baz = { + readonly foo?: string; + };" + `); + }); + + test('should resolve intersection on type literals', async () => { + const code = ` + type Baz = { + foo: string; + } & { + bar: number; + }; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Baz = { + foo: string; + bar: number; + };" + `); + }); + + test('should resolve intersection with an empty type', async () => { + const code = ` + type Foo = { + foo: string; + }; + + type Baz = Foo & {}; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + }; + type Baz = Foo;" + `); + }); + + test('should resolve a simple Omit', async () => { + const code = ` + type Foo = { + foo: string; + }; + + type Baz = (arg: Omit) => void; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + }; + type Baz = (arg: Foo) => void;" + `); + }); + + test('should resolve Omit with keyof', async () => { + const code = ` + type Foo = { + foo: string; + }; + + type Bar = { + bar: number; + }; + + type Baz = (arg: Omit) => void; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + }; + type Bar = { + bar: number; + }; + type Baz = (arg: Foo) => void;" + `); + }); + + test('should resolve Omit with keyof {}', async () => { + const code = ` + type Foo = { + foo: string; + }; + + type baz = Omit + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + }; + type baz = Foo;" + `); + }); + + test('should resolve Omit with keyof when types overlap', async () => { + const code = ` + type Foo = { + foo: string; + bar: number; + }; + + type Baz = (arg: Omit) => void; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + bar: number; + }; + type Baz = (arg: Omit) => void;" + `); + }); + + test('should resolve Omit when types overlap', async () => { + const code = ` + type Foo = { + foo: string; + bar: number; + }; + + type Bar = { + bar: number; + baz: string; + }; + + type Baz = (arg: Omit) => void; + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + foo: string; + bar: number; + }; + type Bar = { + bar: number; + baz: string; + }; + type Baz = (arg: Omit) => void;" + `); + }); + + test('should resolve Omit keys when the object type is unresolvable', async () => { + const code = ` + type Foo = Omit + `; + + const result = await applyPostTransforms(code); + expect(result).toMatchInlineSnapshot( + `"type Foo = Omit;"`, + ); + }); +}); diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/alignTypeParameters.js b/scripts/build-types/transforms/typescript/simplifyTypes/alignTypeParameters.js new file mode 100644 index 00000000000..2fc8b81258e --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/alignTypeParameters.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {NodePath} from '@babel/traverse'; + +import traverse from '@babel/traverse'; + +const t = require('@babel/types'); + +export default function alignTypeParameters( + node: BabelNodeTSType, + declarationPath: NodePath, + path: NodePath, +): BabelNodeTSType { + const declarationTypeParameters = declarationPath.node.typeParameters?.params; + const nodeTypeParameters = path.node.typeParameters?.params; + + if (!declarationTypeParameters || !nodeTypeParameters) { + throw new Error( + `No type parameters found for ${declarationPath.node.id.name ?? ''}`, + ); + } + + if (nodeTypeParameters.length > declarationTypeParameters.length) { + throw new Error( + `Encountered ${nodeTypeParameters.length} type parameters for type ${declarationPath.node.id.name ?? ''} while at maximum ${declarationTypeParameters.length} are allowed`, + ); + } + + const genericMapping = new Map(); + for (let i = 0; i < declarationTypeParameters.length; i++) { + const declarationTypeParameter = declarationTypeParameters[i]; + let nodeTypeParameter: ?BabelNodeTSType = nodeTypeParameters[ + i + ] as $FlowFixMe; + + if (nodeTypeParameter == null) { + nodeTypeParameter = declarationTypeParameter.default; + } + + if (nodeTypeParameter == null) { + throw new Error( + `No value provided for a required type parameter ${declarationPath.node.id.name ?? ''}`, + ); + } + + genericMapping.set(declarationTypeParameter.name, nodeTypeParameter); + } + + // handle edge case where the generic type is equal to one of the type + // parameters, i.e. `type Foo = T` + if (t.isTSTypeReference(node) && t.isIdentifier(node.typeName)) { + const mappedType = genericMapping.get(node.typeName.name); + if (mappedType) { + return mappedType; + } + } + + const wrapped = t.tsTypeAliasDeclaration( + t.identifier('Wrapper'), + undefined, + node, + ); + + traverse(t.file(t.program([wrapped])), { + TSTypeReference(innerPath) { + const type = innerPath.node.typeName; + if (!t.isIdentifier(type)) { + return; + } + + const mappedType = t.cloneDeep(genericMapping.get(type.name)); + if (!mappedType) { + return; + } + + innerPath.replaceWith(mappedType); + innerPath.skip(); + }, + }); + + return node; +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/contextStack.js b/scripts/build-types/transforms/typescript/simplifyTypes/contextStack.js new file mode 100644 index 00000000000..65a250787b6 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/contextStack.js @@ -0,0 +1,136 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {InlineVisitorState} from './visitorState'; + +type KeyofLayer = { + type: 'keyof', +}; + +type OmitLayer = { + type: 'omit', +}; + +type UnionLayer = { + type: 'union', +}; + +type ExtendsLayer = { + type: 'extends', +}; + +type ArrayLayer = { + type: 'array', +}; + +type TypeAliasLayer = { + type: 'typeAlias', + typeParams?: string[], +}; + +type TypeParameterInstantiationLayer = { + type: 'typeParameterInstantiation', +}; + +type UnresolvableTypeLayer = { + type: 'unresolvableType', +}; + +type ResolvableTypeLayer = { + type: 'resolvableType', +}; + +export type StackLayer = + | KeyofLayer + | OmitLayer + | UnionLayer + | ExtendsLayer + | ArrayLayer + | TypeAliasLayer + | TypeParameterInstantiationLayer + | UnresolvableTypeLayer + | ResolvableTypeLayer; + +export function pushLayer(state: InlineVisitorState, layer: StackLayer) { + state.stack.push(layer); +} + +export function popLayer(state: InlineVisitorState, type: StackLayer['type']) { + const top = state.stack[state.stack.length - 1]; + if (!top || top.type !== type) { + throw new Error( + `Unexpected stack state. Expected ${type}, got ${top.type}`, + ); + } + state.stack.pop(); +} + +export function isDefiningType( + state: InlineVisitorState, + alias: string, +): boolean { + return state.parentTypeAliases?.has(alias) ?? false; +} + +export function insideKeyofLayer(state: InlineVisitorState): boolean { + return state.stack.some(layer => layer.type === 'keyof'); +} + +export function insideOmitLayer(state: InlineVisitorState): boolean { + return state.stack.some(layer => layer.type === 'omit'); +} + +export function insideUnionLayer(state: InlineVisitorState): boolean { + return state.stack.some(layer => layer.type === 'union'); +} + +export function insideExtendsLayer(state: InlineVisitorState): boolean { + return state.stack.some(layer => layer.type === 'extends'); +} + +export function insideArrayLayer(state: InlineVisitorState): boolean { + return state.stack.some(layer => layer.type === 'array'); +} + +export function insideTypeAliasLayerWithTypeParam( + state: InlineVisitorState, + parameter: string, +): boolean { + return state.stack.some( + layer => + layer.type === 'typeAlias' && + layer.typeParams?.includes(parameter) === true, + ); +} + +export function insideIndexedAccessLayer(state: InlineVisitorState): boolean { + return state.stack.some(layer => layer.type === 'indexedAccess'); +} + +export function insideUnresolvableTypeInstantiation( + state: InlineVisitorState, +): boolean { + const lastUnresolvableTypeIdx = state.stack.findLastIndex( + layer => layer.type === 'unresolvableType', + ); + const lastResolvableTypeIdx = state.stack.findLastIndex( + layer => layer.type === 'resolvableType', + ); + const lastTypeParameterInstantiationIdx = state.stack.findLastIndex( + layer => layer.type === 'typeParameterInstantiation', + ); + + return ( + lastUnresolvableTypeIdx >= 0 && + lastTypeParameterInstantiationIdx >= 0 && + lastResolvableTypeIdx < lastUnresolvableTypeIdx + ); +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/gatherTypeAliasesVisitor.js b/scripts/build-types/transforms/typescript/simplifyTypes/gatherTypeAliasesVisitor.js new file mode 100644 index 00000000000..6383f91a403 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/gatherTypeAliasesVisitor.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {NodePath, Visitor} from '@babel/traverse'; + +const t = require('@babel/types'); + +export type GatherTypeAliasesVisitorState = { + +aliasToPathMap: Map>, +}; + +/** + * Gather all type aliases in the file into a map + */ +const gatherTypeAliasesVisitor: Visitor = { + TSTypeAliasDeclaration(path, state) { + const alias = path.node.id.name; + state.aliasToPathMap.set(alias, path); + }, +}; + +export default gatherTypeAliasesVisitor; diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/index.js b/scripts/build-types/transforms/typescript/simplifyTypes/index.js new file mode 100644 index 00000000000..0bedaff69e0 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/index.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {BaseVisitorState} from './visitorState'; +import type {PluginObj} from '@babel/core'; +import type {NodePath} from '@babel/traverse'; + +import gatherTypeAliasesVisitor from './gatherTypeAliasesVisitor'; +import {canResolveBuiltinType, resolveBuiltinType} from './resolveBuiltinType'; +import {resolveIntersection} from './resolveIntersection'; +import {resolveTSType} from './resolveTSType'; + +const t = require('@babel/types'); + +const mergeObjects: PluginObj = { + visitor: { + Program: { + enter(path, state): void { + state.aliasToPathMap = new Map< + string, + NodePath, + >(); + state.nodeToAliasMap = new Map(); + state.parentTypeAliases = new Set(); + + path.traverse(gatherTypeAliasesVisitor, { + aliasToPathMap: state.aliasToPathMap, + }); + }, + + exit(path, state): void {}, + }, + TSIntersectionType: { + enter() { + // Do nothing + }, + exit(path, state) { + resolveIntersection(path, state); + }, + }, + TSTypeReference: { + enter(path, state): void {}, + // Builtin-type resolution is done bottom-up + exit(path, state) { + if (!t.isIdentifier(path.node.typeName)) { + return; + } + + const typeName = path.node.typeName.name; + if (canResolveBuiltinType(typeName)) { + resolveBuiltinType(path, state, resolveTSType); + return; + } + }, + }, + }, +}; + +// Visitor state is only used internally, so we can safely cast to PluginObj. +module.exports = mergeObjects as $FlowFixMe as PluginObj; diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/inlineType.js b/scripts/build-types/transforms/typescript/simplifyTypes/inlineType.js new file mode 100644 index 00000000000..0e1eb745865 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/inlineType.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {InlineVisitorState} from './visitorState'; +import type {NodePath} from '@babel/traverse'; + +import alignTypeParameters from './alignTypeParameters'; + +const t = require('@babel/types'); +const debug = require('debug')('build-types:transforms:inlineTypes'); + +export default function inlineType( + state: InlineVisitorState, + path: NodePath, + alias: string, +): void { + const declarationPath = state.aliasToPathMap?.get(alias); + + if (!declarationPath) { + debug(`No declaration found for ${alias ?? ''}`); + return; + } + + let cloned = t.cloneDeep(declarationPath.node.typeAnnotation); + + // If the inlined type is generic, align the provided type parameters + // to the declaration + if (declarationPath.node.typeParameters) { + cloned = alignTypeParameters(cloned, declarationPath, path); + } + + state.parentTypeAliases?.add(alias); + state.nodeToAliasMap?.set(cloned, alias); + path.replaceWith(cloned); +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/inlineTypesVisitor.js b/scripts/build-types/transforms/typescript/simplifyTypes/inlineTypesVisitor.js new file mode 100644 index 00000000000..cabdf4c5a4c --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/inlineTypesVisitor.js @@ -0,0 +1,256 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {InlineVisitorState} from './visitorState'; +import type {PluginObj} from '@babel/core'; +import type {NodePath} from '@babel/traverse'; + +import { + insideTypeAliasLayerWithTypeParam, + isDefiningType, + popLayer, + pushLayer, +} from './contextStack'; +import gatherTypeAliasesVisitor from './gatherTypeAliasesVisitor'; +import inlineType from './inlineType'; +import {canResolveBuiltinType, resolveBuiltinType} from './resolveBuiltinType'; +import {resolveIntersection} from './resolveIntersection'; +import {resolveTypeOperator} from './resolveTypeOperator'; +import {onNodeExit, shouldSkipInliningType} from './utils'; + +const t = require('@babel/types'); + +const inlineTypes: PluginObj = { + visitor: { + Program: { + enter(path, state): void { + if (!state.aliasToPathMap) { + state.aliasToPathMap = new Map< + string, + NodePath, + >(); + + path.traverse(gatherTypeAliasesVisitor, { + aliasToPathMap: state.aliasToPathMap, + }); + } + + state.parentTypeAliases = new Set(); + state.nodeToAliasMap = new Map(); + state.stack = []; + }, + exit(path, state): void { + // Do nothing + }, + }, + TSTypeLiteral: { + enter() { + // Do nothing + }, + exit(path, state) { + onNodeExit(state, path.node); + }, + }, + TSFunctionType: { + enter() { + // Do nothing + }, + exit(path, state) { + onNodeExit(state, path.node); + }, + }, + TSTypeOperator: { + enter(path, state) { + if (path.node.operator === 'keyof') { + pushLayer(state, {type: 'keyof'}); + } + }, + exit(path, state) { + if (path.node.operator === 'keyof') { + popLayer(state, 'keyof'); + } + resolveTypeOperator(path, state); + }, + }, + TSTypeParameter: { + enter(path, state) { + if (path.node.constraint) { + pushLayer(state, {type: 'extends'}); + } + }, + exit(path, state) { + if (path.node.constraint) { + popLayer(state, 'extends'); + } + }, + }, + TSTypeParameterInstantiation: { + enter(path, state) { + pushLayer(state, {type: 'typeParameterInstantiation'}); + }, + exit(path, state) { + popLayer(state, 'typeParameterInstantiation'); + }, + }, + TSTypeAliasDeclaration: { + enter(path, state): void { + state.parentTypeAliases?.add(path.node.id.name); + pushLayer(state, { + type: 'typeAlias', + typeParams: path.node.typeParameters?.params?.map( + param => param.name, + ), + }); + }, + exit(path, state): void { + state.parentTypeAliases?.delete(path.node.id.name); + popLayer(state, 'typeAlias'); + }, + }, + TSUnionType: { + enter(path, state): void { + pushLayer(state, {type: 'union'}); + }, + exit(path, state): void { + popLayer(state, 'union'); + }, + }, + TSIntersectionType: { + enter() { + // Do nothing + }, + exit(path, state) { + resolveIntersection(path, state); + }, + }, + // Classes are a very special case, because they can extend a super class, + // and that super class can be generic. We need to track that superclass + // either as a resolvable or unresolvable type layer. + ClassDeclaration: { + enter(path, state): void { + if (!path.node.superClass || !path.node.superTypeParameters) { + // No generic superclass, don't care + return; + } + + if ( + t.isIdentifier(path.node.superClass) && + canResolveBuiltinType(path.node.superClass.name) + ) { + pushLayer(state, {type: 'resolvableType'}); + } else { + pushLayer(state, {type: 'unresolvableType'}); + } + }, + exit(path, state): void { + if (!path.node.superClass || !path.node.superTypeParameters) { + // No generic superclass, don't care + return; + } + + if ( + t.isIdentifier(path.node.superClass) && + canResolveBuiltinType(path.node.superClass.name) + ) { + popLayer(state, 'resolvableType'); + } else { + popLayer(state, 'unresolvableType'); + } + }, + }, + TSTypeReference: { + // Reference inlining is done top-down + enter(path, state): void { + if ( + t.isTSQualifiedName(path.node.typeName) && + !!path.node.typeParameters + ) { + // A generic qualified name, we need to push an unresolvable layer + pushLayer(state, {type: 'unresolvableType'}); + return; + } + + if (path.node.typeName.type !== 'Identifier') { + return; + } + + const typeName = path.node.typeName.name; + if (isDefiningType(state, typeName)) { + // Skipping recursive type + path.skip(); + return; + } + + if (typeName === 'Array' || typeName === 'ReadonlyArray') { + pushLayer(state, {type: 'array'}); + return; + } + + if (canResolveBuiltinType(typeName)) { + pushLayer(state, {type: 'resolvableType'}); + + if (typeName === 'Omit') { + pushLayer(state, {type: 'omit'}); + } + // Builtin type, do nothing. Will be resolved on exit. + return; + } + + if (insideTypeAliasLayerWithTypeParam(state, typeName)) { + return; + } + + if (shouldSkipInliningType(state, typeName)) { + return; + } + + inlineType(state, path, typeName); + }, + // Builtin-type resolution is done bottom-up + exit(path, state) { + if ( + t.isTSQualifiedName(path.node.typeName) && + !!path.node.typeParameters + ) { + // A generic qualified name, we need to pop the unresolvable layer + popLayer(state, 'unresolvableType'); + return; + } + + if (path.node.typeName.type !== 'Identifier') { + return; + } + + const typeName = path.node.typeName.name; + + if (typeName === 'Array' || typeName === 'ReadonlyArray') { + popLayer(state, 'array'); + return; + } + + if (canResolveBuiltinType(typeName)) { + if (typeName === 'Omit') { + popLayer(state, 'omit'); + } + + popLayer(state, 'resolvableType'); + resolveBuiltinType(path, state); + return; + } + + state.parentTypeAliases?.delete(typeName); + }, + }, + }, +}; + +// Visitor state is only used internally, so we can safely cast to PluginObj. +module.exports = inlineTypes as $FlowFixMe as PluginObj; diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/resolveBuiltinType.js b/scripts/build-types/transforms/typescript/simplifyTypes/resolveBuiltinType.js new file mode 100644 index 00000000000..a05fb1ae53d --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/resolveBuiltinType.js @@ -0,0 +1,249 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {TSTypeResolver} from './resolveTSType'; +import type {BaseVisitorState} from './visitorState'; +import type {NodePath} from '@babel/traverse'; + +import {replaceWithCleanup} from './utils'; + +const t = require('@babel/types'); +const debug = require('debug')('build-types:transforms:inlineTypes'); + +// TODO: Handle more builtin TS types +const builtinTypeResolvers: { + +[K: string]: ( + path: NodePath, + state: BaseVisitorState, + tsTypeResolver?: TSTypeResolver, + ) => void, +} = { + Omit: (path, state, tsTypeResolver) => { + if ( + !path.node.typeParameters || + path.node.typeParameters.params.length !== 2 + ) { + throw new Error( + `Omit type must have exactly 2 type parameters. Got ${path.node.typeParameters?.params.length ?? 0}`, + ); + } + + const [objectType, keys] = path.node.typeParameters.params; + // Try to resolve the second type parameter to a literal or union of literals + const resolvedKeys = tsTypeResolver?.(keys, state.aliasToPathMap) ?? keys; + + if (t.isTSNeverKeyword(resolvedKeys)) { + // never is just an empty union, so we can just replace the Omit with + // the object type + replaceWithCleanup(state, path, objectType); + path.skip(); // We don't want to traverse the new node + return; + } + + const stringLiteralElements = (() => { + if (t.isTSLiteralType(keys) && t.isStringLiteral(keys.literal)) { + return [keys.literal.value]; + } + + if (t.isTSUnionType(resolvedKeys)) { + const unionElements = resolvedKeys.types; + return unionElements + .map(element => + t.isTSLiteralType(element) && t.isStringLiteral(element.literal) + ? element.literal + : null, + ) + .filter(literal => literal !== null) + .map(literal => literal.value); + } + + debug(`Unsupported Omit second parameter: ${keys.type}`); + return 'skip' as const; + })(); + + if (stringLiteralElements === 'skip') { + return; + } + + // At this point, we know that the keys are a union of string literals + + if (!t.isTSTypeLiteral(objectType)) { + // The parameter is not a literal, we can try to resolve it to a literal + const constBody = tsTypeResolver?.(objectType, state.aliasToPathMap); + + if (!constBody || !t.isTSTypeLiteral(constBody)) { + // Resolving the parameter failed, so we cannot do anything but update + // the second type parameter to the resolved keys. + if (path.node.typeParameters) { + path.node.typeParameters.params[1] = resolvedKeys; + } + return; + } + + // Resolving the parameter succeeded, so we can compare the keys to the + // members of the object type + const members = constBody.members + .map(member => { + if (t.isIdentifier(member.key)) { + return member.key.name; + } + + if (t.isLiteral(member.key)) { + return member.key.value; + } + }) + .filter(member => member); + + const overlappingKeys = stringLiteralElements.filter(key => + members.includes(key), + ); + + // If there are no overlapping keys, we can just replace the Omit with + // the object type + if (overlappingKeys.length === 0) { + replaceWithCleanup(state, path, objectType); + path.skip(); // We don't want to traverse the new node + return; + } + + // If there are overlapping keys, we can leave only the relevant ones + if (path.node.typeParameters) { + path.node.typeParameters.params[1] = t.tsUnionType( + overlappingKeys.map(key => t.tsLiteralType(t.stringLiteral(key))), + ); + } + + return; + } + + // objectType is a TSTypeLiteral, so we can just remove the omitted members + if ( + // Only performing the resolution on objects with non-computed string keys. + !objectType.members.every( + member => + (t.isTSPropertySignature(member) || t.isTSMethodSignature(member)) && + (t.isIdentifier(member.key) || t.isLiteral(member.key)), + ) + ) { + debug(`Unsupported Omit first parameter: ${objectType.type}`); + return; + } + + replaceWithCleanup( + state, + path, + t.tsTypeLiteral( + objectType.members.filter(member => { + const propNode = (member as $FlowFixMe as t.TSPropertySignature) + .key as $FlowFixMe as t.Identifier | t.Literal; + const propName = + propNode.type === 'Identifier' ? propNode.name : propNode.value; + + return !stringLiteralElements.includes(propName); + }), + ), + ); + + path.skip(); // We don't want to traverse the new node + }, + Readonly: (path, state) => { + if ( + !path.node.typeParameters || + path.node.typeParameters.params.length !== 1 + ) { + throw new Error( + `Readonly type must have exactly 1 type parameter. Got ${path.node.typeParameters?.params.length ?? 0}`, + ); + } + + const [objectType] = path.node.typeParameters.params; + + if (!t.isTSTypeLiteral(objectType)) { + // The parameter was not inlined, so we cannot do anything. + return; + } + + replaceWithCleanup( + state, + path, + t.tsTypeLiteral( + (t.cloneDeep(objectType).members ?? []).map(member => { + if ( + t.isTSMethodSignature(member) || + t.isTSCallSignatureDeclaration(member) || + t.isTSConstructSignatureDeclaration(member) + ) { + return member; + } + member.readonly = true; + return member; + }), + ), + ); + + path.skip(); // We don't want to traverse the new node + }, + Partial: (path, state) => { + if ( + !path.node.typeParameters || + path.node.typeParameters.params.length !== 1 + ) { + throw new Error( + `Partial type must have exactly 1 type parameter. Got ${path.node.typeParameters?.params.length ?? 0}`, + ); + } + + const [objectType] = path.node.typeParameters.params; + + if (!t.isTSTypeLiteral(objectType)) { + // The parameter was not inlined, so we cannot do anything. + return; + } + + replaceWithCleanup( + state, + path, + t.tsTypeLiteral( + (t.cloneDeep(objectType).members ?? []).map(member => { + if ( + t.isTSMethodSignature(member) || + t.isTSCallSignatureDeclaration(member) || + t.isTSConstructSignatureDeclaration(member) || + t.isTSIndexSignature(member) + ) { + return member; + } + member.optional = true; + return member; + }), + ), + ); + + path.skip(); // We don't want to traverse the new node + }, +}; + +export function canResolveBuiltinType(name: string): boolean { + return builtinTypeResolvers.hasOwnProperty(name); +} + +export function resolveBuiltinType( + path: NodePath, + state: BaseVisitorState, + tsTypeResolver?: TSTypeResolver, +): void { + const name = path.node.typeName.name; + if (name != null && builtinTypeResolvers.hasOwnProperty(name)) { + builtinTypeResolvers[name](path, state, tsTypeResolver); + } else { + debug(`Unsupported builtin type: ${name ?? 'undefined'}`); + } +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/resolveIntersection.js b/scripts/build-types/transforms/typescript/simplifyTypes/resolveIntersection.js new file mode 100644 index 00000000000..09cc353c921 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/resolveIntersection.js @@ -0,0 +1,159 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {BaseVisitorState} from './visitorState'; +import type {NodePath} from '@babel/traverse'; + +import {replaceWithCleanup} from './utils'; + +const t = require('@babel/types'); +const debug = require('debug')('build-types:transforms:inlineTypes'); + +export function resolveIntersection( + path: NodePath, + state: BaseVisitorState, +): void { + const newTypes: Array = []; + const requiredKeys = new Set(); + const mutableKeys = new Set(); + const combinedMembers: Record< + string, + {node: BabelNodeTSTypeAnnotation, leadingComments?: BabelNodeComment[]}[], + > = {}; + + for (const type of path.node.types) { + if (!t.isTSTypeLiteral(type)) { + newTypes.push(type); // Leave it as is, nothing to do here + continue; + } + + const members = type.members; + if (members.length === 0) { + // Empty object, lets omit it + continue; + } + + const membersObj = members.reduce( + (acc, member) => { + if (t.isTSPropertySignature(member)) { + const key = member.key; + if (t.isIdentifier(key) && member.typeAnnotation) { + acc.map[key.name] = member; + } else if (t.isStringLiteral(key) && member.typeAnnotation) { + acc.map[key.value] = member; + } else { + acc.unsupported = true; + debug(`Unsupported object literal property key: ${key.type}`); + } + } else { + acc.unsupported = true; + debug(`Unsupported object literal type key: ${member.type}`); + } + + return acc; + }, + { + map: {} as Record, + unsupported: false, + }, + ); + + if (membersObj.unsupported) { + // We cannot combine this type with the others, so we leave it as is + newTypes.push(type); + continue; + } + + for (const [key, prop] of Object.entries(membersObj.map)) { + const annotation = prop.typeAnnotation; + if (!annotation) { + continue; + } + + // $FlowExpectedError[sketchy-null-bool] Missing prop is the same as false + if (!prop.optional) { + requiredKeys.add(key); + } + + // $FlowExpectedError[sketchy-null-bool] Missing prop is the same as false + if (!prop.readonly) { + mutableKeys.add(key); + } + + if (!combinedMembers[key]) { + combinedMembers[key] = [ + {node: annotation, leadingComments: prop.leadingComments}, + ]; + } else { + combinedMembers[key].push({ + node: annotation, + leadingComments: prop.leadingComments, + }); + } + } + } + + // Creating a type literal from the combined keys + const combinedLiteral = t.tsTypeLiteral( + Object.entries(combinedMembers).map(([key, values]) => { + const keyNode = t.isValidIdentifier(key) + ? t.identifier(key) + : t.stringLiteral(key); + + const prop = t.tsPropertySignature( + keyNode, + values.length === 1 + ? values[0].node + : t.tsTypeAnnotation( + t.tsIntersectionType( + values.map(value => value.node.typeAnnotation), + ), + ), + ); + + prop.optional = !requiredKeys.has(key); + prop.readonly = !mutableKeys.has(key); + prop.leadingComments = values + .map(value => value.leadingComments) + .filter(Boolean) + .flat(); + + return prop; + }), + ); + + if (combinedLiteral.members.length !== 0) { + newTypes.push(combinedLiteral); + } + + let newNode; + + if (newTypes.length === 0) { + newNode = t.tsTypeReference( + t.identifier('Record'), + t.tsTypeParameterInstantiation([t.tsStringKeyword(), t.tsNeverKeyword()]), + ); + } else if (newTypes.length === 1) { + newNode = newTypes[0]; + } else { + newNode = t.tsIntersectionType(newTypes); + } + + const alias = state.nodeToAliasMap?.get(path.node); + if (alias !== undefined) { + // Inheriting alias from the node we're replacing + state.nodeToAliasMap.set(newNode, alias); + state.nodeToAliasMap.delete(path.node); + } + + replaceWithCleanup(state, path, newNode); + path.skip(); // We don't want to traverse the new node +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/resolveTSType.js b/scripts/build-types/transforms/typescript/simplifyTypes/resolveTSType.js new file mode 100644 index 00000000000..ef8aa81e187 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/resolveTSType.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {NodePath} from '@babel/traverse'; + +import traverse from '@babel/traverse'; + +const inlineTypes = require('./inlineTypesVisitor'); +const t = require('@babel/types'); + +export type TSTypeResolver = ( + node: BabelNodeTSType, + aliasToPathMap: Map>, +) => ?BabelNodeTSType; + +export const resolveTSType: TSTypeResolver = (node, aliasToPathMap) => { + if (t.isTSTypeLiteral(node)) { + return node; + } + + if (!t.isTSTypeLiteral(node)) { + let body: ?BabelNodeTSType = null; + const wrapperAst = t.file( + t.program([ + t.exportNamedDeclaration( + t.tsTypeAliasDeclaration( + t.identifier('__WrapperAlias'), + undefined, + t.cloneDeep(node), + ), + ), + ]), + ); + + traverse(wrapperAst, inlineTypes.visitor, undefined, { + aliasToPathMap: aliasToPathMap, + }); + + traverse(wrapperAst, { + TSTypeAliasDeclaration(innerPath) { + if (innerPath.node.id.name !== '__WrapperAlias') { + return; + } + + body = innerPath.node.typeAnnotation; + innerPath.stop(); + }, + }); + + if (!body) { + return; + } + const constBody = body; + + if (t.isTSTypeLiteral(constBody)) { + // Only performing the resolution on objects with non-computed string keys. + if ( + !constBody.members.every( + member => + (t.isTSPropertySignature(member) || + t.isTSMethodSignature(member)) && + (t.isIdentifier(member.key) || t.isLiteral(member.key)), + ) + ) { + return; + } + } + + return constBody; + } +}; diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/resolveTypeOperator.js b/scripts/build-types/transforms/typescript/simplifyTypes/resolveTypeOperator.js new file mode 100644 index 00000000000..1821629cd04 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/resolveTypeOperator.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {BaseVisitorState} from './visitorState'; +import type {NodePath} from '@babel/traverse'; + +import {replaceWithCleanup} from './utils'; + +const t = require('@babel/types'); +const debug = require('debug')('build-types:transforms:inlineTypes'); + +const typeOperatorResolvers: { + +[K: string]: ( + path: NodePath, + state: BaseVisitorState, + ) => void, +} = { + keyof: (path, state) => { + const unionElements: Array = []; + + const typeAnnotation = path.node.typeAnnotation; + if (t.isTSTypeLiteral(typeAnnotation)) { + for (const member of typeAnnotation.members) { + if (t.isTSPropertySignature(member) || t.isTSMethodSignature(member)) { + if (t.isIdentifier(member.key)) { + unionElements.push( + t.tsLiteralType(t.stringLiteral(member.key.name)), + ); + } else if (t.isStringLiteral(member.key)) { + unionElements.push( + t.tsLiteralType(t.stringLiteral(member.key.value)), + ); + } else { + debug( + `Unsupported object literal property key: ${member.key.type}`, + ); + return; + } + } else { + debug(`Unsupported object literal type key: ${member.type}`); + return; + } + } + } else { + debug(`Unsupported type operand for keyof: ${typeAnnotation.type}`); + return; + } + + replaceWithCleanup( + state, + path, + unionElements.length === 0 + ? t.tsNeverKeyword() + : t.tsUnionType(unionElements), + ); + + path.skip(); // We don't want to traverse the new node + }, +}; + +export function canResolveTypeOperator(name: string): boolean { + return typeOperatorResolvers.hasOwnProperty(name); +} + +export function resolveTypeOperator( + path: NodePath, + state: BaseVisitorState, +): void { + const name = path.node.operator; + if (canResolveTypeOperator(name)) { + typeOperatorResolvers[name](path, state); + } else { + debug(`Unsupported type operator: ${name}`); + } +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/utils.js b/scripts/build-types/transforms/typescript/simplifyTypes/utils.js new file mode 100644 index 00000000000..bf2d2e7e43c --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/utils.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {BaseVisitorState, InlineVisitorState} from './visitorState'; +import type {NodePath} from '@babel/traverse'; + +import { + insideArrayLayer, + insideExtendsLayer, + insideKeyofLayer, + insideOmitLayer, + insideUnionLayer, + insideUnresolvableTypeInstantiation, +} from './contextStack'; + +const t = require('@babel/types'); + +export function onNodeExit(state: BaseVisitorState, node: t.Node): void { + // We're no longer inside the node if we remove it + const alias = state.nodeToAliasMap?.get(node); + if (alias !== undefined) { + state.parentTypeAliases?.delete(alias); + } +} + +export function replaceWithCleanup( + state: BaseVisitorState, + path: NodePath, + newNode: t.Node, +) { + if (newNode === path.node) { + // Nothing to do + return; + } + + // Treating `newNode` as a template, and instantiating it + const clonedNewNode = t.cloneDeep(newNode); + + // We're removing the node, so let's treat it as if we're exiting it + onNodeExit(state, path.node); + + path.replaceWith(clonedNewNode); +} + +export function shouldSkipInliningType( + state: InlineVisitorState, + alias: string, +): boolean { + // Type instantiations of types that are not resolvable + if (insideUnresolvableTypeInstantiation(state)) { + return true; + } + + // Skipping inline of union/array types, except when inside keyof or omit + if ( + (insideUnionLayer(state) || + insideArrayLayer(state) || + insideExtendsLayer(state)) && + !(insideKeyofLayer(state) || insideOmitLayer(state)) + ) { + return true; + } + + return false; +} diff --git a/scripts/build-types/transforms/typescript/simplifyTypes/visitorState.js b/scripts/build-types/transforms/typescript/simplifyTypes/visitorState.js new file mode 100644 index 00000000000..5d1fe11cbb1 --- /dev/null +++ b/scripts/build-types/transforms/typescript/simplifyTypes/visitorState.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {StackLayer} from './contextStack'; +import type {NodePath} from '@babel/traverse'; + +const t = require('@babel/types'); + +export type BaseVisitorState = { + aliasToPathMap: Map>, + parentTypeAliases?: Set, + nodeToAliasMap: Map, + ... +}; + +export type InlineVisitorState = { + ...BaseVisitorState, + /** + * Layers of operators that the current node is inside of. + * Used to determine if we should inline a type. + */ + stack: Array, + ... +};