Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions fixtures/components/discriminated-union/list/index.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends ListProps.Item> {
/** The items to display. */
items: ReadonlyArray<T>;
}

interface WithCustomRender<T extends ListProps.Item> {
/** 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<T extends ListProps.Item = ListProps.Item> = BaseProps<T> &
(WithCustomRender<T> | WithDefaultRender);

export namespace ListProps {
export interface Item {
id: string;
}
}

export default function List({ items }: ListProps) {
return <div>{items.length}</div>;
}
4 changes: 4 additions & 0 deletions fixtures/components/discriminated-union/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["./**/*.tsx"]
}
15 changes: 12 additions & 3 deletions src/shared/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions test/components/discriminated-union.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
59 changes: 58 additions & 1 deletion test/shared/type-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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');
});
Loading