diff --git a/fixtures/components/string-intersection/code-editor/index.tsx b/fixtures/components/string-intersection/code-editor/index.tsx
new file mode 100644
index 0000000..923d441
--- /dev/null
+++ b/fixtures/components/string-intersection/code-editor/index.tsx
@@ -0,0 +1,21 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+
+export namespace CodeEditorProps {
+ // This simulates the pattern used in the code editor where we want:
+ // 1. Autocomplete for known language literals
+ // 2. Allow custom string values
+ export type Language = 'javascript' | 'html' | 'ruby' | 'python' | 'java' | (string & { _?: undefined });
+}
+
+export interface CodeEditorProps {
+ /**
+ * Specifies the programming language.
+ */
+ language: CodeEditorProps.Language;
+}
+
+export default function CodeEditor({ language }: CodeEditorProps) {
+ return
Code Editor
;
+}
diff --git a/fixtures/components/string-intersection/tsconfig.json b/fixtures/components/string-intersection/tsconfig.json
new file mode 100644
index 0000000..8afdfb3
--- /dev/null
+++ b/fixtures/components/string-intersection/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../tsconfig.json",
+ "include": ["./**/*.tsx"]
+}
diff --git a/src/components/object-definition.ts b/src/components/object-definition.ts
index 73f8e95..f6f607c 100644
--- a/src/components/object-definition.ts
+++ b/src/components/object-definition.ts
@@ -86,11 +86,25 @@ export function getObjectDefinition(
return { type };
}
-function getPrimitiveType(type: ts.UnionOrIntersectionType) {
- if (type.types.every(subtype => subtype.isStringLiteral())) {
+function isStringLiteralOrStringIntersection(subtype: ts.Type, checker: ts.TypeChecker): boolean {
+ // Check if it's a string literal
+ if (subtype.isStringLiteral() || subtype.flags & ts.TypeFlags.StringLiteral) {
+ return true;
+ }
+ if (subtype.isIntersection()) {
+ const stringified = stringifyType(subtype, checker);
+ if (stringified.startsWith('string &')) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function getPrimitiveType(type: ts.UnionOrIntersectionType, checker: ts.TypeChecker) {
+ if (type.types.every(subtype => isStringLiteralOrStringIntersection(subtype, checker))) {
return 'string';
}
- if (type.types.every(subtype => subtype.isNumberLiteral())) {
+ if (type.types.every(subtype => subtype.isNumberLiteral() || subtype.flags & ts.TypeFlags.NumberLiteral)) {
return 'number';
}
return undefined;
@@ -103,10 +117,21 @@ function getUnionTypeDefinition(
checker: ts.TypeChecker
): { type: string; inlineType: UnionTypeDefinition } {
const valueDescriptions = extractValueDescriptions(realType, typeNode);
- const primitiveType = getPrimitiveType(realType);
- const values = realType.types.map(subtype =>
- primitiveType ? (subtype as ts.LiteralType).value.toString() : stringifyType(subtype, checker)
- );
+ const primitiveType = getPrimitiveType(realType, checker);
+ const values = realType.types.map(subtype => {
+ if (primitiveType === 'string') {
+ if (subtype.isStringLiteral()) {
+ return (subtype as ts.LiteralType).value.toString();
+ }
+ if (subtype.isIntersection()) {
+ return 'string';
+ }
+ }
+ if (primitiveType === 'number' && subtype.isNumberLiteral()) {
+ return (subtype as ts.LiteralType).value.toString();
+ }
+ return stringifyType(subtype, checker);
+ });
return {
type: primitiveType ?? realTypeName,
diff --git a/test/components/object-definition.test.ts b/test/components/object-definition.test.ts
new file mode 100644
index 0000000..3310c9a
--- /dev/null
+++ b/test/components/object-definition.test.ts
@@ -0,0 +1,105 @@
+// 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 simpleComponent: ComponentDefinition;
+let complexTypesComponents: ComponentDefinition[];
+
+beforeAll(() => {
+ const simpleResult = buildProject('simple');
+ expect(simpleResult).toHaveLength(1);
+ [simpleComponent] = simpleResult;
+
+ complexTypesComponents = buildProject('complex-types');
+ expect(complexTypesComponents.length).toBeGreaterThan(0);
+});
+
+test('object definition should handle basic types', () => {
+ expect(simpleComponent.name).toBe('Simple');
+ expect(simpleComponent.properties).toBeDefined();
+});
+
+test('object definition should handle union types correctly', () => {
+ // Find a component with union types
+ const componentWithUnions = complexTypesComponents.find(comp =>
+ comp.properties.some(prop => prop.inlineType?.type === 'union')
+ );
+
+ if (componentWithUnions) {
+ const unionProp = componentWithUnions.properties.find(def => def.inlineType?.type === 'union');
+
+ if (unionProp?.inlineType?.type === 'union') {
+ expect(unionProp.inlineType.values).toBeDefined();
+ expect(Array.isArray(unionProp.inlineType.values)).toBe(true);
+ expect(unionProp.inlineType.values.length).toBeGreaterThan(0);
+ }
+ }
+});
+
+test('object definition should handle string literal unions', () => {
+ // Test string literal union handling
+ const componentWithStringUnions = complexTypesComponents.find(comp =>
+ comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type === 'string')
+ );
+
+ if (componentWithStringUnions) {
+ const stringUnionProp = componentWithStringUnions.properties.find(
+ prop => prop.inlineType?.type === 'union' && prop.type === 'string'
+ );
+
+ if (stringUnionProp?.inlineType?.type === 'union') {
+ expect(stringUnionProp.type).toBe('string');
+ expect(stringUnionProp.inlineType.values).toBeDefined();
+ // Should contain string values
+ expect(stringUnionProp.inlineType.values.some(v => typeof v === 'string')).toBe(true);
+ }
+ }
+});
+
+test('object definition should handle number literal unions', () => {
+ // Test number literal union handling
+ const componentWithNumberUnions = complexTypesComponents.find(comp =>
+ comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type === 'number')
+ );
+
+ if (componentWithNumberUnions) {
+ const numberUnionProp = componentWithNumberUnions.properties.find(
+ prop => prop.inlineType?.type === 'union' && prop.type === 'number'
+ );
+
+ if (numberUnionProp?.inlineType?.type === 'union') {
+ expect(numberUnionProp.type).toBe('number');
+ expect(numberUnionProp.inlineType.values).toBeDefined();
+ }
+ }
+});
+
+test('object definition should preserve type information', () => {
+ const props = simpleComponent.properties;
+ expect(props.length).toBeGreaterThan(0);
+
+ props.forEach(prop => {
+ expect(prop.name).toBeDefined();
+ expect(prop.type).toBeDefined();
+ });
+});
+
+test('object definition should handle mixed union types', () => {
+ // Test mixed union types (not primitive)
+ const componentWithMixedUnions = complexTypesComponents.find(comp =>
+ comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type !== 'string' && prop.type !== 'number')
+ );
+
+ if (componentWithMixedUnions) {
+ const mixedUnionProp = componentWithMixedUnions.properties.find(
+ prop => prop.inlineType?.type === 'union' && prop.type !== 'string' && prop.type !== 'number'
+ );
+
+ if (mixedUnionProp?.inlineType?.type === 'union') {
+ expect(mixedUnionProp.inlineType.values).toBeDefined();
+ expect(mixedUnionProp.inlineType.name).toBeDefined();
+ }
+ }
+});
diff --git a/test/components/string-intersection.test.ts b/test/components/string-intersection.test.ts
new file mode 100644
index 0000000..30243d0
--- /dev/null
+++ b/test/components/string-intersection.test.ts
@@ -0,0 +1,101 @@
+// 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 codeEditor: ComponentDefinition;
+
+beforeAll(() => {
+ const result = buildProject('string-intersection');
+ expect(result).toHaveLength(1);
+ [codeEditor] = result;
+});
+
+test('should properly handle union types with string intersection for custom values', () => {
+ const languageProp = codeEditor.properties.find(def => def.name === 'language');
+
+ expect(languageProp?.name).toBe('language');
+ expect(languageProp?.description).toBe('Specifies the programming language.');
+ expect(languageProp?.optional).toBe(false);
+ expect(languageProp?.type).toBe('string');
+
+ // Check inline type structure
+ expect(languageProp?.inlineType?.name).toBe('CodeEditorProps.Language');
+ expect(languageProp?.inlineType?.type).toBe('union');
+ if (languageProp?.inlineType?.type === 'union') {
+ expect(languageProp.inlineType.valueDescriptions).toBeUndefined();
+ }
+
+ // The intersection type "string & { _?: undefined; }" should be converted to "string"
+ // String literal values should appear without quotes
+ const values = (languageProp?.inlineType as any)?.values;
+ expect(values).toHaveLength(6);
+ expect(values).toContain('javascript');
+ expect(values).toContain('html');
+ expect(values).toContain('ruby');
+ expect(values).toContain('python');
+ expect(values).toContain('java');
+ expect(values).toContain('string'); // The intersection type becomes "string" to indicate custom values are allowed
+});
+
+test('should treat the union as primitive string type', () => {
+ const languageProp = codeEditor.properties.find(def => def.name === 'language');
+
+ // The type should be 'string' not the full union name
+ expect(languageProp?.type).toBe('string');
+});
+
+test('should convert intersection helper to "string" in values array', () => {
+ const languageProp = codeEditor.properties.find(def => def.name === 'language');
+
+ // Should not contain the raw "string & { _?: undefined; }" syntax
+ const hasRawIntersectionType =
+ languageProp?.inlineType?.type === 'union' &&
+ languageProp.inlineType.values.some((value: string) => value.includes('string &') || value.includes('_?:'));
+
+ expect(hasRawIntersectionType).toBe(false);
+
+ // But should contain "string" to indicate custom values are allowed
+ const hasStringValue =
+ languageProp?.inlineType?.type === 'union' && languageProp.inlineType.values.includes('string');
+
+ expect(hasStringValue).toBe(true);
+});
+
+test('should detect intersection types with string & pattern', () => {
+ const languageProp = codeEditor.properties.find(def => def.name === 'language');
+
+ // Verify that the union contains both string literals and the intersection type
+ expect(languageProp?.inlineType?.type).toBe('union');
+
+ if (languageProp?.inlineType?.type === 'union') {
+ const values = languageProp.inlineType.values;
+
+ // Should have the literal values
+ expect(values).toContain('javascript');
+ expect(values).toContain('html');
+ expect(values).toContain('ruby');
+ expect(values).toContain('python');
+ expect(values).toContain('java');
+
+ // Should have 'string' representing the intersection type
+ expect(values).toContain('string');
+
+ // All values should be treated as string type (primitive detection)
+ expect(languageProp.type).toBe('string');
+ }
+});
+
+test('should recognize intersection type as string-compatible', () => {
+ const languageProp = codeEditor.properties.find(def => def.name === 'language');
+
+ // The union with intersection type should be recognized as primitive string
+ expect(languageProp?.type).toBe('string');
+
+ if (languageProp?.inlineType?.type === 'union') {
+ // All values in the union should be compatible with string type
+ const allValuesAreStrings = languageProp.inlineType.values.every((value: string) => typeof value === 'string');
+ expect(allValuesAreStrings).toBe(true);
+ }
+});