fix(#19, #20): support complex unions and literal unions#25
Conversation
|
🎉 This PR is included in version 3.0.2 🎉 The release is available on: Your semantic-release bot 📦🚀 |
There was a problem hiding this comment.
Pull request overview
This PR adds support for both literal unions (e.g., "home" | "work" | "other") and complex unions (e.g., BooleanValue | Int64Value) to address issues #19 and #20. Literal unions are emitted as enum-like arrays, while complex unions use ElectroDB's CustomAttributeType with a TypeScript type annotation.
- Literal unions are now emitted as arrays of string values (like enums)
- Complex unions are emitted using
CustomAttributeTypewith proper TypeScript type annotations - Added RawCode marker class to support emitting raw code expressions during code generation
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/main.tsp | Adds test models for complex unions (Int64Value, BooleanValue, Info) and adds additionalInfo field to Person model to test union handling |
| test/entities.test.js | Updates test description for literal union behavior and adds comprehensive test suite for complex union types with CustomAttributeType |
| src/stringify.ts | Introduces RawCode class for emitting raw code expressions and enables TypeScript parser plugin for handling generic syntax |
| src/emitter.ts | Adds emitTypeToTypeScript for TypeScript type generation, isLiteralUnion to detect simple literal unions, emitUnion to handle both union types, and conditional import of CustomAttributeType |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function emitTypeToTypeScript(type: Type): string { | ||
| switch (type.kind) { | ||
| case "Scalar": { | ||
| let baseType = type; | ||
| while (baseType.baseScalar) { | ||
| baseType = baseType.baseScalar; | ||
| } | ||
| switch (baseType.name) { | ||
| case "boolean": | ||
| return "boolean"; | ||
| case "numeric": | ||
| case "integer": | ||
| case "float": | ||
| case "int64": | ||
| case "int32": | ||
| case "int16": | ||
| case "int8": | ||
| case "uint64": | ||
| case "uint32": | ||
| case "uint16": | ||
| case "uint8": | ||
| case "safeint": | ||
| case "float32": | ||
| case "float64": | ||
| case "decimal": | ||
| case "decimal128": | ||
| return "number"; | ||
| default: | ||
| return "string"; | ||
| } | ||
| } | ||
| case "Model": { | ||
| if (type.name === "Array") { | ||
| const arrayType = type as ArrayModelType; | ||
| return `${emitTypeToTypeScript(arrayType.indexer.value)}[]`; | ||
| } | ||
| const properties: string[] = []; | ||
| for (const prop of walkPropertiesInherited(type as RecordModelType)) { | ||
| const optional = prop.optional ? "?" : ""; | ||
| properties.push( | ||
| `${prop.name}${optional}: ${emitTypeToTypeScript(prop.type)}`, | ||
| ); | ||
| } | ||
| return `{ ${properties.join("; ")} }`; | ||
| } | ||
| case "Enum": { | ||
| const values = Array.from(type.members) | ||
| .map(([key, member]) => `"${member.value ?? key}"`) | ||
| .join(" | "); | ||
| return values; | ||
| } | ||
| case "Union": { | ||
| const variants = Array.from(type.variants.values()) | ||
| .map((variant) => emitTypeToTypeScript(variant.type)) | ||
| .join(" | "); | ||
| return variants; | ||
| } | ||
| default: | ||
| return "any"; | ||
| } | ||
| } |
There was a problem hiding this comment.
The emitTypeToTypeScript function duplicates scalar type mapping logic that already exists in emitIntrinsincScalar (lines 24-50). Consider refactoring to reuse the existing function to improve maintainability and reduce the risk of inconsistencies.
For example, the Scalar case could be simplified to:
case "Scalar": {
let baseType = type;
while (baseType.baseScalar) {
baseType = baseType.baseScalar;
}
return emitIntrinsincScalar(baseType);
}| function emitTypeToTypeScript(type: Type): string { | ||
| switch (type.kind) { | ||
| case "Scalar": { | ||
| let baseType = type; | ||
| while (baseType.baseScalar) { | ||
| baseType = baseType.baseScalar; | ||
| } | ||
| switch (baseType.name) { | ||
| case "boolean": | ||
| return "boolean"; | ||
| case "numeric": | ||
| case "integer": | ||
| case "float": | ||
| case "int64": | ||
| case "int32": | ||
| case "int16": | ||
| case "int8": | ||
| case "uint64": | ||
| case "uint32": | ||
| case "uint16": | ||
| case "uint8": | ||
| case "safeint": | ||
| case "float32": | ||
| case "float64": | ||
| case "decimal": | ||
| case "decimal128": | ||
| return "number"; | ||
| default: | ||
| return "string"; | ||
| } | ||
| } | ||
| case "Model": { | ||
| if (type.name === "Array") { | ||
| const arrayType = type as ArrayModelType; | ||
| return `${emitTypeToTypeScript(arrayType.indexer.value)}[]`; | ||
| } | ||
| const properties: string[] = []; | ||
| for (const prop of walkPropertiesInherited(type as RecordModelType)) { | ||
| const optional = prop.optional ? "?" : ""; | ||
| properties.push( | ||
| `${prop.name}${optional}: ${emitTypeToTypeScript(prop.type)}`, | ||
| ); | ||
| } | ||
| return `{ ${properties.join("; ")} }`; | ||
| } | ||
| case "Enum": { | ||
| const values = Array.from(type.members) | ||
| .map(([key, member]) => `"${member.value ?? key}"`) | ||
| .join(" | "); | ||
| return values; | ||
| } | ||
| case "Union": { | ||
| const variants = Array.from(type.variants.values()) | ||
| .map((variant) => emitTypeToTypeScript(variant.type)) | ||
| .join(" | "); | ||
| return variants; | ||
| } | ||
| default: | ||
| return "any"; | ||
| } | ||
| } | ||
|
|
||
| function isLiteralUnion(type: Union): string[] | null { | ||
| const literals: string[] = []; | ||
|
|
||
| for (const variant of type.variants.values()) { | ||
| // Check if this variant is a string or number literal | ||
| if (variant.type.kind === "String") { | ||
| literals.push(variant.type.value); | ||
| } else if (variant.type.kind === "Number") { | ||
| literals.push(String(variant.type.value)); | ||
| } else { | ||
| // Not a literal union, return null | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| return literals; | ||
| } | ||
|
|
||
| function emitUnion(type: Union): Attribute { | ||
| // Check if this is a simple literal union (e.g., "home" | "work" | "other") | ||
| const literals = isLiteralUnion(type); | ||
| if (literals) { | ||
| // Emit as enum-like array, similar to how named enums are handled | ||
| return { | ||
| type: literals, | ||
| }; | ||
| } | ||
|
|
||
| // Complex union - use CustomAttributeType | ||
| const tsType = emitTypeToTypeScript(type); | ||
| // RawCode is used to emit the CustomAttributeType function call as-is | ||
| return { | ||
| // @ts-expect-error - RawCode is handled by stringifyObject at code generation time | ||
| type: new RawCode(`CustomAttributeType<${tsType}>("any")`), | ||
| }; | ||
| } |
There was a problem hiding this comment.
These new functions (emitTypeToTypeScript, isLiteralUnion, and emitUnion) lack documentation explaining their purpose, parameters, and return values. Adding JSDoc comments would improve code maintainability and help other developers understand the code's intent.
For example:
/**
* Converts a TypeSpec type to its TypeScript type representation as a string.
* @param type - The TypeSpec type to convert
* @returns A string representing the TypeScript type
*/
function emitTypeToTypeScript(type: Type): string {
No description provided.