diff --git a/fixtures/components/discriminated-union/list/index.tsx b/fixtures/components/discriminated-union/list/index.tsx new file mode 100644 index 0000000..b0246c4 --- /dev/null +++ b/fixtures/components/discriminated-union/list/index.tsx @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +interface BaseProps { + /** The items to display. */ + items: ReadonlyArray; +} + +interface WithCustomRender { + /** Custom render function for each item. */ + renderItem: () => React.ReactNode | null; + /** The currently active item. */ + activeItem: T | undefined; +} + +interface WithDefaultRender { + /** Not applicable when using the default renderer. */ + renderItem?: never; + /** Not applicable when using the default renderer. */ + activeItem?: never; +} + +export type ListProps = BaseProps & + (WithCustomRender | WithDefaultRender); + +export namespace ListProps { + export interface Item { + id: string; + } +} + +export default function List({ items }: ListProps) { + return
{items.length}
; +} diff --git a/fixtures/components/discriminated-union/tsconfig.json b/fixtures/components/discriminated-union/tsconfig.json new file mode 100644 index 0000000..8afdfb3 --- /dev/null +++ b/fixtures/components/discriminated-union/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.tsx"] +} diff --git a/src/shared/type-utils.ts b/src/shared/type-utils.ts index 4137f14..e484da7 100644 --- a/src/shared/type-utils.ts +++ b/src/shared/type-utils.ts @@ -77,10 +77,19 @@ export function tryExtractDeclaration(symbol: ts.Symbol): ts.Declaration | undef if (!declarations || declarations.length === 0) { return undefined; } - if (declarations.length > 1) { - throw new Error(`Multiple declarations found for symbol: ${symbol.getName()}`); + if (declarations.length === 1) { + return declarations[0]; } - return declarations[0]; + // Multiple property signature declarations occur in discriminated unions where the same + // property appears in multiple branches (e.g. `prop: SomeType` in one and `prop?: never` in + // another). Keep the branches that aren't `never` and use that declaration when it's unambiguous. + if (declarations.every(ts.isPropertySignature)) { + const nonNever = declarations.filter(decl => decl.type?.kind !== ts.SyntaxKind.NeverKeyword); + if (nonNever.length === 1) { + return nonNever[0]; + } + } + throw new Error(`Multiple declarations found for symbol: ${symbol.getName()}`); } export function isOptionalSymbol(symbol: ts.Symbol): boolean { diff --git a/test/components/discriminated-union.test.ts b/test/components/discriminated-union.test.ts new file mode 100644 index 0000000..4d5c1c5 --- /dev/null +++ b/test/components/discriminated-union.test.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { expect, test, beforeAll } from 'vitest'; +import { ComponentDefinition } from '../../src/components/interfaces'; +import { buildProject } from './test-helpers'; + +let list: ComponentDefinition; + +beforeAll(() => { + const result = buildProject('discriminated-union'); + expect(result).toHaveLength(1); + + list = result[0]; +}); + +test('should extract properties from discriminated union without errors', () => { + expect(list.name).toBe('List'); + const propNames = list.properties.map(p => p.name); + expect(propNames).toContain('items'); + expect(propNames).toContain('renderItem'); + expect(propNames).toContain('activeItem'); +}); + +test('should use the non-never declaration type for shared properties', () => { + const renderProp = list.properties.find(p => p.name === 'renderItem')!; + expect(renderProp).toBeDefined(); + expect(renderProp.type).toBe('() => React.ReactNode'); +}); + +test('should preserve description from discriminated union properties', () => { + const renderProp = list.properties.find(p => p.name === 'renderItem')!; + expect(renderProp.description).toBeDefined(); +}); diff --git a/test/shared/type-utils.test.ts b/test/shared/type-utils.test.ts index f995983..497906d 100644 --- a/test/shared/type-utils.test.ts +++ b/test/shared/type-utils.test.ts @@ -3,7 +3,8 @@ import ts from 'typescript'; import { test, expect } from 'vitest'; -import { printFlags } from '../../lib/shared/type-utils'; +import { printFlags, tryExtractDeclaration } from '../../src/shared/type-utils'; +import { getInMemoryProject } from '../components/test-helpers'; test('serialises node flags', () => { const flags = printFlags( @@ -20,3 +21,59 @@ test('serialises type flags', () => { expect(flags).toContain('Number'); expect(flags).toContain('Enum'); }); + +test('tryExtractDeclaration returns undefined for symbols with no declarations', () => { + const symbol = { getDeclarations: () => undefined } as unknown as ts.Symbol; + expect(tryExtractDeclaration(symbol)).toBeUndefined(); +}); + +test('tryExtractDeclaration returns the single declaration', () => { + const decl = {} as ts.Declaration; + const symbol = { getDeclarations: () => [decl] } as unknown as ts.Symbol; + expect(tryExtractDeclaration(symbol)).toBe(decl); +}); + +test('tryExtractDeclaration picks non-never property signature from multiple declarations', () => { + const { exportSymbol, checker } = getInMemoryProject(` + interface BranchA { shared: string; } + interface BranchB { shared?: never; } + export type Combined = BranchA | BranchB; + `); + const combinedType = checker.getDeclaredTypeOfSymbol(exportSymbol); + const sharedSymbol = combinedType.getProperties().find(p => p.getName() === 'shared')!; + const declaration = tryExtractDeclaration(sharedSymbol); + expect(declaration).toBeDefined(); + // Should pick the BranchA declaration (non-never) + if (declaration && ts.isPropertySignature(declaration) && declaration.type) { + expect(declaration.type.kind).not.toBe(ts.SyntaxKind.NeverKeyword); + } +}); + +test('tryExtractDeclaration keeps a property signature that has no type annotation', () => { + const { exportSymbol, checker } = getInMemoryProject(` + interface BranchA { shared; } + interface BranchB { shared?: never; } + export type Combined = BranchA | BranchB; + `); + const combinedType = checker.getDeclaredTypeOfSymbol(exportSymbol); + const sharedSymbol = combinedType.getProperties().find(p => p.getName() === 'shared')!; + const declaration = tryExtractDeclaration(sharedSymbol); + expect(declaration).toBeDefined(); + expect(ts.isPropertySignature(declaration!)).toBe(true); + + expect((declaration as ts.PropertySignature).type).toBeUndefined(); +}); + +test('tryExtractDeclaration throws when multiple non-never declarations are ambiguous', () => { + // Three property signature declarations, two of which are non-never (`string` and `number`). + // The result is ambiguous, so no single declaration can be chosen and the function throws. + const { exportSymbol, checker } = getInMemoryProject(` + interface BranchA { shared: string; } + interface BranchB { shared: number; } + interface BranchC { shared?: never; } + export type Combined = BranchA | BranchB | BranchC; + `); + const combinedType = checker.getDeclaredTypeOfSymbol(exportSymbol); + const sharedSymbol = combinedType.getProperties().find(p => p.getName() === 'shared')!; + expect(() => tryExtractDeclaration(sharedSymbol)).toThrow('Multiple declarations found for symbol: shared'); +});