From 699a4538877591911c5fe90b5b889283f95db378 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 14 Oct 2025 01:28:30 +0100 Subject: [PATCH] add @default, don't inline scalars unless @inline --- packages/emitter/lib/decorators.tsp | 25 ++ packages/emitter/src/decorators.ts | 17 ++ packages/emitter/src/emitter.ts | 251 ++++++++++++++++-- packages/emitter/src/tsp-index.ts | 2 + .../atproto/input/app/bsky/actor/defs.tsp | 2 + .../input/com/example/scalarDefaults.tsp | 30 +++ .../basic/input/com/example/scalarDefs.tsp | 22 ++ .../basic/input/com/example/scalarInline.tsp | 22 ++ .../basic/input/com/example/unionDefaults.tsp | 53 ++++ .../output/com/example/scalarDefaults.json | 45 ++++ .../basic/output/com/example/scalarDefs.json | 34 +++ .../output/com/example/scalarInline.json | 28 ++ .../output/com/example/unionDefaults.json | 61 +++++ 13 files changed, 576 insertions(+), 16 deletions(-) create mode 100644 packages/emitter/test/spec/basic/input/com/example/scalarDefaults.tsp create mode 100644 packages/emitter/test/spec/basic/input/com/example/scalarDefs.tsp create mode 100644 packages/emitter/test/spec/basic/input/com/example/scalarInline.tsp create mode 100644 packages/emitter/test/spec/basic/input/com/example/unionDefaults.tsp create mode 100644 packages/emitter/test/spec/basic/output/com/example/scalarDefaults.json create mode 100644 packages/emitter/test/spec/basic/output/com/example/scalarDefs.json create mode 100644 packages/emitter/test/spec/basic/output/com/example/scalarInline.json create mode 100644 packages/emitter/test/spec/basic/output/com/example/unionDefaults.json diff --git a/packages/emitter/lib/decorators.tsp b/packages/emitter/lib/decorators.tsp index 8354b5a..8e43344 100644 --- a/packages/emitter/lib/decorators.tsp +++ b/packages/emitter/lib/decorators.tsp @@ -162,6 +162,31 @@ extern dec encoding(target: unknown, mime: valueof string); */ extern dec errors(target: unknown, ...errors: unknown[]); +/** + * Specifies a default value for a scalar or union definition. + * Only valid on standalone scalar or union defs (not @inline). + * The value must match the underlying type (string, integer, or boolean). + * For unions with token refs, you can pass a model reference directly. + * + * @param value - The default value (literal or model reference for tokens) + * + * @example Scalar with default + * ```typespec + * @default("standard") + * scalar Mode extends string; + * ``` + * + * @example Union with token default + * ```typespec + * @default(Inperson) + * union EventMode { Hybrid, Inperson, Virtual, string } + * + * @token + * model Inperson {} + * ``` + */ +extern dec `default`(target: unknown, value: unknown); + /** * Marks a namespace as external, preventing it from emitting JSON output. * This decorator can only be applied to namespaces. diff --git a/packages/emitter/src/decorators.ts b/packages/emitter/src/decorators.ts index 8d15668..e7a4005 100644 --- a/packages/emitter/src/decorators.ts +++ b/packages/emitter/src/decorators.ts @@ -25,6 +25,7 @@ const inlineKey = Symbol("inline"); const maxBytesKey = Symbol("maxBytes"); const minBytesKey = Symbol("minBytes"); const externalKey = Symbol("external"); +const defaultKey = Symbol("default"); /** * @maxBytes decorator for maximum length of bytes type @@ -296,6 +297,22 @@ export function isReadOnly(program: Program, target: Type): boolean { return program.stateSet(readOnlyKey).has(target); } +/** + * @default decorator for setting default values on scalars and unions + * The value can be a literal (string, number, boolean) or a model reference for tokens + */ +export function $default(context: DecoratorContext, target: Type, value: any) { + // Just store the raw value - let the emitter handle unwrapping and validation + context.program.stateMap(defaultKey).set(target, value); +} + +export function getDefault( + program: Program, + target: Type, +): any | undefined { + return program.stateMap(defaultKey).get(target); +} + /** * @external decorator for marking a namespace as external * External namespaces are skipped during emission and don't produce JSON files diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index 8b74710..945d15d 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -68,6 +68,7 @@ import { getMaxBytes, getMinBytes, isExternal, + getDefault, } from "./decorators.js"; export interface EmitterOptions { @@ -98,6 +99,48 @@ export class TypelexEmitter { private options: EmitterOptions, ) {} + /** + * Process the raw default value from the decorator, unwrapping TypeSpec value objects + * and returning either a primitive (string, number, boolean) or a Type (for model references) + */ + private processDefaultValue(rawValue: any): string | number | boolean | Type | undefined { + if (rawValue === undefined) return undefined; + + // TypeSpec may wrap values - check if this is a value object first + if (rawValue && typeof rawValue === 'object' && rawValue.valueKind) { + if (rawValue.valueKind === "StringValue") { + return rawValue.value; + } else if (rawValue.valueKind === "NumericValue" || rawValue.valueKind === "NumberValue") { + return rawValue.value; + } else if (rawValue.valueKind === "BooleanValue") { + return rawValue.value; + } + return undefined; // Unsupported valueKind + } + + // Check if it's a Type object (Model, String, Number, Boolean literals) + if (rawValue && typeof rawValue === 'object' && rawValue.kind) { + if (rawValue.kind === "String") { + return (rawValue as StringLiteral).value; + } else if (rawValue.kind === "Number") { + return (rawValue as NumericLiteral).value; + } else if (rawValue.kind === "Boolean") { + return (rawValue as BooleanLiteral).value; + } else if (rawValue.kind === "Model") { + // Return the model itself for token references + return rawValue as Model; + } + return undefined; // Unsupported kind + } + + // Direct primitive value + if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') { + return rawValue; + } + + return undefined; + } + async emit() { const globalNs = this.program.getGlobalNamespaceType(); @@ -356,8 +399,8 @@ export class TypelexEmitter { } private addScalarToDefs(lexicon: LexiconDoc, scalar: Scalar) { + // Only skip if the scalar itself is in TypeSpec namespace (built-in scalars) if (scalar.namespace?.name === "TypeSpec") return; - if (scalar.baseScalar?.namespace?.name === "TypeSpec") return; // Skip @inline scalars - they should be inlined, not defined separately if (isInline(this.program, scalar)) { @@ -368,7 +411,43 @@ export class TypelexEmitter { const scalarDef = this.scalarToLexiconPrimitive(scalar, undefined); if (scalarDef) { const description = getDoc(this.program, scalar); - lexicon.defs[defName] = { ...scalarDef, description } as LexUserType; + + // Apply @default decorator if present + const rawDefault = getDefault(this.program, scalar); + const defaultValue = this.processDefaultValue(rawDefault); + let defWithDefault: any = { ...scalarDef }; + + if (defaultValue !== undefined) { + // Check if it's a Type (model reference for tokens) + if (typeof defaultValue === 'object' && 'kind' in defaultValue) { + // For model references, we need to resolve to NSID + // This shouldn't happen for scalars, only unions support token refs + this.program.reportDiagnostic({ + code: "invalid-default-on-scalar", + severity: "error", + message: "@default on scalars must be a literal value (string, number, or boolean), not a model reference", + target: scalar, + }); + } else { + // Validate that the default value matches the type + this.assertValidValueForType(scalarDef.type, defaultValue, scalar); + defWithDefault = { ...defWithDefault, default: defaultValue }; + } + } + + // Apply integer constraints for standalone scalar defs + if (scalarDef.type === "integer") { + const minValue = getMinValue(this.program, scalar); + if (minValue !== undefined) { + (defWithDefault as any).minimum = minValue; + } + const maxValue = getMaxValue(this.program, scalar); + if (maxValue !== undefined) { + (defWithDefault as any).maximum = maxValue; + } + } + + lexicon.defs[defName] = { ...defWithDefault, description } as LexUserType; } } @@ -391,7 +470,44 @@ export class TypelexEmitter { if (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) { const defName = name.charAt(0).toLowerCase() + name.slice(1); const description = getDoc(this.program, union); - lexicon.defs[defName] = { ...unionDef, description }; + + // Apply @default decorator if present + const rawDefault = getDefault(this.program, union); + const defaultValue = this.processDefaultValue(rawDefault); + let defWithDefault: any = { ...unionDef }; + + if (defaultValue !== undefined) { + // Check if it's a Type (model reference for tokens) + if (typeof defaultValue === 'object' && 'kind' in defaultValue) { + // Resolve the model reference to its NSID + const tokenModel = defaultValue as Model; + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true + if (tokenRef) { + defWithDefault = { ...defWithDefault, default: tokenRef }; + } else { + this.program.reportDiagnostic({ + code: "invalid-default-token", + severity: "error", + message: "@default value must be a valid token model reference", + target: union, + }); + } + } else { + // Literal value - validate it matches the union type + if (typeof defaultValue !== "string") { + this.program.reportDiagnostic({ + code: "invalid-default-value-type", + severity: "error", + message: `Default value type mismatch: expected string, got ${typeof defaultValue}`, + target: union, + }); + } else { + defWithDefault = { ...defWithDefault, default: defaultValue }; + } + } + } + + lexicon.defs[defName] = { ...defWithDefault, description }; } else if (unionDef.type === "union") { this.program.reportDiagnostic({ code: "union-refs-not-allowed-as-def", @@ -401,6 +517,30 @@ export class TypelexEmitter { `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, target: union, }); + } else if (unionDef.type === "integer" && (unionDef as any).enum) { + // Integer enums can also be defs + const defName = name.charAt(0).toLowerCase() + name.slice(1); + const description = getDoc(this.program, union); + + // Apply @default decorator if present + const rawDefault = getDefault(this.program, union); + const defaultValue = this.processDefaultValue(rawDefault); + let defWithDefault = { ...unionDef }; + + if (defaultValue !== undefined) { + if (typeof defaultValue === "number") { + defWithDefault = { ...defWithDefault, default: defaultValue }; + } else { + this.program.reportDiagnostic({ + code: "invalid-default-value-type", + severity: "error", + message: `Default value type mismatch: expected integer, got ${typeof defaultValue}`, + target: union, + }); + } + } + + lexicon.defs[defName] = { ...defWithDefault, description }; } } @@ -1158,6 +1298,32 @@ export class TypelexEmitter { prop?: ModelProperty, propDesc?: string, ): LexObjectProperty | null { + // Check if this scalar should be referenced instead of inlined + const scalarRef = this.getScalarReference(scalar); + if (scalarRef) { + // Check if property has a default value that would conflict with the scalar's @default + if (prop?.defaultValue !== undefined) { + const scalarDefaultRaw = getDefault(this.program, scalar); + const scalarDefault = this.processDefaultValue(scalarDefaultRaw); + const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); + + // If the scalar has a different default, or if the property has a default but the scalar doesn't, error + if (scalarDefault !== propDefault) { + this.program.reportDiagnostic({ + code: "conflicting-defaults", + severity: "error", + message: scalarDefault !== undefined + ? `Property default value conflicts with scalar's @default decorator. The scalar "${scalar.name}" has @default(${JSON.stringify(scalarDefault)}) but property has default value ${JSON.stringify(propDefault)}. Either remove the property default, mark the scalar @inline, or make the defaults match.` + : `Property has a default value but the referenced scalar "${scalar.name}" does not. Either add @default to the scalar, mark it @inline to allow property-level defaults, or remove the property default.`, + target: prop, + }); + } + } + + return { type: "ref" as const, ref: scalarRef, description: propDesc }; + } + + // Inline the scalar const primitive = this.scalarToLexiconPrimitive(scalar, prop); if (!primitive) return null; @@ -1246,6 +1412,32 @@ export class TypelexEmitter { if (!isDefining) { const unionRef = this.getUnionReference(unionType); if (unionRef) { + // Check if property has a default value that would conflict with the union's @default + if (prop?.defaultValue !== undefined) { + const unionDefaultRaw = getDefault(this.program, unionType); + const unionDefault = this.processDefaultValue(unionDefaultRaw); + const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); + + // For union defaults that are model references, we need to resolve them for comparison + let resolvedUnionDefault: string | number | boolean | undefined = unionDefault as any; + if (unionDefault && typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { + const ref = this.getModelReference(unionDefault as Model, true); + resolvedUnionDefault = ref || undefined; + } + + // If the union has a different default, or if the property has a default but the union doesn't, error + if (resolvedUnionDefault !== propDefault) { + this.program.reportDiagnostic({ + code: "conflicting-defaults", + severity: "error", + message: unionDefault !== undefined + ? `Property default value conflicts with union's @default decorator. The union "${unionType.name}" has @default(${JSON.stringify(resolvedUnionDefault)}) but property has default value ${JSON.stringify(propDefault)}. Either remove the property default, mark the union @inline, or make the defaults match.` + : `Property has a default value but the referenced union "${unionType.name}" does not. Either add @default to the union, mark it @inline to allow property-level defaults, or remove the property default.`, + target: prop, + }); + } + } + return { type: "ref" as const, ref: unionRef, description: propDesc }; } } @@ -1271,14 +1463,14 @@ export class TypelexEmitter { // Check if this scalar (or its base) is bytes type if (this.isScalarOfType(scalar, "bytes")) { const byteDef: LexBytes = { type: "bytes" }; - const target = prop || scalar; - const minLength = getMinBytes(this.program, target); + // Check scalar first for its own constraints, then property overrides + const minLength = getMinBytes(this.program, scalar) ?? (prop ? getMinBytes(this.program, prop) : undefined); if (minLength !== undefined) { byteDef.minLength = minLength; } - const maxLength = getMaxBytes(this.program, target); + const maxLength = getMaxBytes(this.program, scalar) ?? (prop ? getMaxBytes(this.program, prop) : undefined); if (maxLength !== undefined) { byteDef.maxLength = maxLength; } @@ -1310,32 +1502,33 @@ export class TypelexEmitter { // Apply string constraints if (primitive.type === "string") { - const target = prop || scalar; - const maxLength = getMaxLength(this.program, target); + // Check scalar first for its own constraints, then property overrides + const maxLength = getMaxLength(this.program, scalar) ?? (prop ? getMaxLength(this.program, prop) : undefined); if (maxLength !== undefined) { primitive.maxLength = maxLength; } - const minLength = getMinLength(this.program, target); + const minLength = getMinLength(this.program, scalar) ?? (prop ? getMinLength(this.program, prop) : undefined); if (minLength !== undefined) { primitive.minLength = minLength; } - const maxGraphemes = getMaxGraphemes(this.program, target); + const maxGraphemes = getMaxGraphemes(this.program, scalar) ?? (prop ? getMaxGraphemes(this.program, prop) : undefined); if (maxGraphemes !== undefined) { primitive.maxGraphemes = maxGraphemes; } - const minGraphemes = getMinGraphemes(this.program, target); + const minGraphemes = getMinGraphemes(this.program, scalar) ?? (prop ? getMinGraphemes(this.program, prop) : undefined); if (minGraphemes !== undefined) { primitive.minGraphemes = minGraphemes; } } // Apply numeric constraints - if (prop && primitive.type === "integer") { - const minValue = getMinValue(this.program, prop); + if (primitive.type === "integer") { + // Check scalar first for its own constraints, then property overrides + const minValue = getMinValue(this.program, scalar) ?? (prop ? getMinValue(this.program, prop) : undefined); if (minValue !== undefined) { primitive.minimum = minValue; } - const maxValue = getMaxValue(this.program, prop); + const maxValue = getMaxValue(this.program, scalar) ?? (prop ? getMaxValue(this.program, prop) : undefined); if (maxValue !== undefined) { primitive.maximum = maxValue; } @@ -1431,7 +1624,7 @@ export class TypelexEmitter { private assertValidValueForType( primitiveType: string, value: unknown, - prop: ModelProperty, + target: ModelProperty | Scalar | Union, ): void { const valid = (primitiveType === "boolean" && typeof value === "boolean") || @@ -1442,7 +1635,7 @@ export class TypelexEmitter { code: "invalid-default-value-type", severity: "error", message: `Default value type mismatch: expected ${primitiveType}, got ${typeof value}`, - target: prop, + target: target, }); } } @@ -1509,6 +1702,32 @@ export class TypelexEmitter { return this.getReference(union, union.name, union.namespace); } + private getScalarReference(scalar: Scalar): string | null { + // Built-in TypeSpec scalars (string, integer, boolean themselves) should not be referenced + if (scalar.namespace?.name === "TypeSpec") return null; + + // @inline scalars should be inlined, not referenced + if (isInline(this.program, scalar)) return null; + + // Scalars without names or namespace can't be referenced + if (!scalar.name || !scalar.namespace) return null; + + const defName = scalar.name.charAt(0).toLowerCase() + scalar.name.slice(1); + const namespaceName = getNamespaceFullName(scalar.namespace); + if (!namespaceName) return null; + + // Local reference (same namespace) - use short ref + if ( + this.currentLexiconId === namespaceName || + this.currentLexiconId === `${namespaceName}.defs` + ) { + return `#${defName}`; + } + + // Cross-namespace reference + return `${namespaceName}#${defName}`; + } + private modelToLexiconArray( model: Model, prop?: ModelProperty, diff --git a/packages/emitter/src/tsp-index.ts b/packages/emitter/src/tsp-index.ts index f20114c..1ee37ec 100644 --- a/packages/emitter/src/tsp-index.ts +++ b/packages/emitter/src/tsp-index.ts @@ -15,6 +15,7 @@ import { $maxBytes, $minBytes, $external, + $default, } from "./decorators.js"; /** @internal */ @@ -36,5 +37,6 @@ export const $decorators = { maxBytes: $maxBytes, minBytes: $minBytes, external: $external, + default: $default, }, }; diff --git a/packages/emitter/test/integration/atproto/input/app/bsky/actor/defs.tsp b/packages/emitter/test/integration/atproto/input/app/bsky/actor/defs.tsp index 6f705f8..cc2bc70 100644 --- a/packages/emitter/test/integration/atproto/input/app/bsky/actor/defs.tsp +++ b/packages/emitter/test/integration/atproto/input/app/bsky/actor/defs.tsp @@ -232,6 +232,7 @@ namespace app.bsky.actor.defs { prioritizeFollowedUsers?: boolean; } + @inline @maxLength(640) @maxGraphemes(64) scalar InterestTag extends string; @@ -292,6 +293,7 @@ namespace app.bsky.actor.defs { @required did: did; } + @inline @maxLength(100) scalar NudgeToken extends string; diff --git a/packages/emitter/test/spec/basic/input/com/example/scalarDefaults.tsp b/packages/emitter/test/spec/basic/input/com/example/scalarDefaults.tsp new file mode 100644 index 0000000..02e1fc8 --- /dev/null +++ b/packages/emitter/test/spec/basic/input/com/example/scalarDefaults.tsp @@ -0,0 +1,30 @@ +import "@typelex/emitter"; + +namespace com.example.scalarDefaults { + /** Test default decorator on scalars */ + model Main { + /** Uses string scalar with default */ + mode?: Mode; + + /** Uses integer scalar with default */ + limit?: Limit; + + /** Uses boolean scalar with default */ + enabled?: Enabled; + } + + /** A string type with a default value */ + @default("standard") + @maxLength(50) + scalar Mode extends string; + + /** An integer type with a default value */ + @default(50) + @minValue(1) + @maxValue(100) + scalar Limit extends integer; + + /** A boolean type with a default value */ + @default(true) + scalar Enabled extends boolean; +} diff --git a/packages/emitter/test/spec/basic/input/com/example/scalarDefs.tsp b/packages/emitter/test/spec/basic/input/com/example/scalarDefs.tsp new file mode 100644 index 0000000..f058cd0 --- /dev/null +++ b/packages/emitter/test/spec/basic/input/com/example/scalarDefs.tsp @@ -0,0 +1,22 @@ +import "@typelex/emitter"; + +namespace com.example.scalarDefs { + /** Scalar defs should create standalone defs like models and unions */ + model Main { + /** Uses a custom string scalar with constraints */ + tag?: Tag; + + /** Uses a custom integer scalar with constraints */ + count?: Count; + } + + /** A custom string type with length constraints */ + @maxLength(100) + @maxGraphemes(50) + scalar Tag extends string; + + /** A custom integer type with value constraints */ + @minValue(1) + @maxValue(100) + scalar Count extends integer; +} diff --git a/packages/emitter/test/spec/basic/input/com/example/scalarInline.tsp b/packages/emitter/test/spec/basic/input/com/example/scalarInline.tsp new file mode 100644 index 0000000..6a79ef4 --- /dev/null +++ b/packages/emitter/test/spec/basic/input/com/example/scalarInline.tsp @@ -0,0 +1,22 @@ +import "@typelex/emitter"; + +namespace com.example.scalarInline { + /** Test inline decorator on scalars */ + model Main { + /** Inline scalar - should not create a def */ + tag?: Tag; + + /** Non-inline scalar - should create a def */ + category?: Category; + } + + /** An inline scalar should be inlined at usage sites */ + @inline + @maxLength(50) + @maxGraphemes(25) + scalar Tag extends string; + + /** A regular scalar should create a standalone def */ + @maxLength(100) + scalar Category extends string; +} diff --git a/packages/emitter/test/spec/basic/input/com/example/unionDefaults.tsp b/packages/emitter/test/spec/basic/input/com/example/unionDefaults.tsp new file mode 100644 index 0000000..2b873d0 --- /dev/null +++ b/packages/emitter/test/spec/basic/input/com/example/unionDefaults.tsp @@ -0,0 +1,53 @@ +import "@typelex/emitter"; + +namespace com.example.unionDefaults { + /** Test default decorator on unions */ + model Main { + /** Union with token refs and default */ + eventMode?: EventMode; + + /** Union with string literals and default */ + sortOrder?: SortOrder; + + /** Union with integer literals and default */ + priority?: Priority; + } + + /** Union of tokens with default pointing to a token */ + @default(Inperson) + union EventMode { + Hybrid, + Inperson, + Virtual, + string, + } + + /** A hybrid event */ + @token + model Hybrid {} + + /** An in-person event */ + @token + model Inperson {} + + /** A virtual event */ + @token + model Virtual {} + + /** Union of string literals with default */ + @default("asc") + union SortOrder { + "asc", + "desc", + string, + } + + /** Union of integer literals with default (closed enum) */ + @default(1) + @closed + union Priority { + 1, + 2, + 3, + } +} diff --git a/packages/emitter/test/spec/basic/output/com/example/scalarDefaults.json b/packages/emitter/test/spec/basic/output/com/example/scalarDefaults.json new file mode 100644 index 0000000..2894ff9 --- /dev/null +++ b/packages/emitter/test/spec/basic/output/com/example/scalarDefaults.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "com.example.scalarDefaults", + "defs": { + "main": { + "type": "object", + "properties": { + "mode": { + "type": "ref", + "ref": "#mode", + "description": "Uses string scalar with default" + }, + "limit": { + "type": "ref", + "ref": "#limit", + "description": "Uses integer scalar with default" + }, + "enabled": { + "type": "ref", + "ref": "#enabled", + "description": "Uses boolean scalar with default" + } + }, + "description": "Test default decorator on scalars" + }, + "mode": { + "type": "string", + "maxLength": 50, + "default": "standard", + "description": "A string type with a default value" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + "description": "An integer type with a default value" + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "A boolean type with a default value" + } + } +} diff --git a/packages/emitter/test/spec/basic/output/com/example/scalarDefs.json b/packages/emitter/test/spec/basic/output/com/example/scalarDefs.json new file mode 100644 index 0000000..52d9e8a --- /dev/null +++ b/packages/emitter/test/spec/basic/output/com/example/scalarDefs.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "com.example.scalarDefs", + "defs": { + "main": { + "type": "object", + "properties": { + "tag": { + "type": "ref", + "ref": "#tag", + "description": "Uses a custom string scalar with constraints" + }, + "count": { + "type": "ref", + "ref": "#count", + "description": "Uses a custom integer scalar with constraints" + } + }, + "description": "Scalar defs should create standalone defs like models and unions" + }, + "tag": { + "type": "string", + "maxLength": 100, + "maxGraphemes": 50, + "description": "A custom string type with length constraints" + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "A custom integer type with value constraints" + } + } +} diff --git a/packages/emitter/test/spec/basic/output/com/example/scalarInline.json b/packages/emitter/test/spec/basic/output/com/example/scalarInline.json new file mode 100644 index 0000000..6ce3e74 --- /dev/null +++ b/packages/emitter/test/spec/basic/output/com/example/scalarInline.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "com.example.scalarInline", + "defs": { + "main": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "maxLength": 50, + "maxGraphemes": 25, + "description": "Inline scalar - should not create a def" + }, + "category": { + "type": "ref", + "ref": "#category", + "description": "Non-inline scalar - should create a def" + } + }, + "description": "Test inline decorator on scalars" + }, + "category": { + "type": "string", + "maxLength": 100, + "description": "A regular scalar should create a standalone def" + } + } +} diff --git a/packages/emitter/test/spec/basic/output/com/example/unionDefaults.json b/packages/emitter/test/spec/basic/output/com/example/unionDefaults.json new file mode 100644 index 0000000..60a419a --- /dev/null +++ b/packages/emitter/test/spec/basic/output/com/example/unionDefaults.json @@ -0,0 +1,61 @@ +{ + "lexicon": 1, + "id": "com.example.unionDefaults", + "defs": { + "main": { + "type": "object", + "properties": { + "eventMode": { + "type": "ref", + "ref": "#eventMode", + "description": "Union with token refs and default" + }, + "sortOrder": { + "type": "ref", + "ref": "#sortOrder", + "description": "Union with string literals and default" + }, + "priority": { + "type": "ref", + "ref": "#priority", + "description": "Union with integer literals and default" + } + }, + "description": "Test default decorator on unions" + }, + "eventMode": { + "type": "string", + "knownValues": [ + "com.example.unionDefaults#hybrid", + "com.example.unionDefaults#inperson", + "com.example.unionDefaults#virtual" + ], + "default": "com.example.unionDefaults#inperson", + "description": "Union of tokens with default pointing to a token" + }, + "hybrid": { + "type": "token", + "description": "A hybrid event" + }, + "inperson": { + "type": "token", + "description": "An in-person event" + }, + "virtual": { + "type": "token", + "description": "A virtual event" + }, + "sortOrder": { + "type": "string", + "knownValues": ["asc", "desc"], + "default": "asc", + "description": "Union of string literals with default" + }, + "priority": { + "type": "integer", + "enum": [1, 2, 3], + "default": 1, + "description": "Union of integer literals with default (closed enum)" + } + } +} -- 2.43.0 From 4425824dec420b88c0fb830370163d0287639f57 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 14 Oct 2025 01:40:01 +0100 Subject: [PATCH] add @default decorator; uninline scalars by default --- DOCS.md | 165 +++++++++++++++++- packages/emitter/lib/decorators.tsp | 40 +++++ packages/emitter/src/emitter.ts | 45 ++++- .../community/lexicon/calendar/event.tsp | 125 +++++++++++++ .../input/community/lexicon/calendar/rsvp.tsp | 41 +++++ .../community/lexicon/calendar/event.json | 120 ++++++++++++- .../community/lexicon/calendar/rsvp.json | 2 +- 7 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp create mode 100644 packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp diff --git a/DOCS.md b/DOCS.md index 6cdc036..cae58eb 100644 --- a/DOCS.md +++ b/DOCS.md @@ -312,6 +312,80 @@ Now `Caption` is expanded inline: Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. +### Scalars + +TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models): + +```typescript +import "@typelex/emitter"; + +namespace com.example { + model Main { + handle?: Handle; + bio?: Bio; + } + + @maxLength(50) + scalar Handle extends string; + + @maxLength(256) + @maxGraphemes(128) + scalar Bio extends string; +} +``` + +This creates three defs: `main`, `handle`, and `bio`: + +```json +{ + "id": "com.example", + "defs": { + "main": { + "type": "object", + "properties": { + "handle": { "type": "ref", "ref": "#handle" }, + "bio": { "type": "ref", "ref": "#bio" } + } + }, + "handle": { + "type": "string", + "maxLength": 50 + }, + "bio": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 128 + } + } +} +``` + +Use `@inline` to expand a scalar inline instead: + +```typescript +import "@typelex/emitter"; + +namespace com.example { + model Main { + handle?: Handle; + } + + @inline + @maxLength(50) + scalar Handle extends string; +} +``` + +Now `Handle` is expanded inline (no separate def): + +```json +// ... +"properties": { + "handle": { "type": "string", "maxLength": 50 } +} +// ... +``` + ## Top-Level Lexicon Types TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. @@ -905,7 +979,9 @@ Maps to: `minLength`/`maxLength` ## Defaults and Constants -### Defaults +### Property Defaults + +You can set default values on properties: ```typescript import "@typelex/emitter"; @@ -920,6 +996,93 @@ namespace com.example { Maps to: `{"default": 1}`, `{"default": "en"}` +### Type Defaults + +You can also set defaults on scalar and union types using the `@default` decorator: + +```typescript +import "@typelex/emitter"; + +namespace com.example { + model Main { + mode?: Mode; + priority?: Priority; + } + + @default("standard") + scalar Mode extends string; + + @default(1) + @closed + @inline + union Priority { 1, 2, 3 } +} +``` + +This creates a default on the type definition itself: + +```json +{ + "defs": { + "mode": { + "type": "string", + "default": "standard" + } + } +} +``` + +For unions with token references, pass the model directly: + +```typescript +import "@typelex/emitter"; + +namespace com.example { + model Main { + eventType?: EventType; + } + + @default(InPerson) + union EventType { Hybrid, InPerson, Virtual, string } + + @token model Hybrid {} + @token model InPerson {} + @token model Virtual {} +} +``` + +This resolves to the fully-qualified token NSID: + +```json +{ + "eventType": { + "type": "string", + "knownValues": [ + "com.example#hybrid", + "com.example#inPerson", + "com.example#virtual" + ], + "default": "com.example#inPerson" + } +} +``` + +**Important:** When a scalar or union creates a standalone def (not `@inline`), property-level defaults must match the type's `@default`. Otherwise you'll get an error: + +```typescript +@default("standard") +scalar Mode extends string; + +model Main { + mode?: Mode = "custom"; // ERROR: Conflicting defaults! +} +``` + +Solutions: +1. Make the defaults match: `mode?: Mode = "standard"` +2. Mark the type `@inline`: Allows property-level defaults +3. Remove the property default: Uses the type's default + ### Constants Use `@readOnly` with a default: diff --git a/packages/emitter/lib/decorators.tsp b/packages/emitter/lib/decorators.tsp index 8e43344..5192a33 100644 --- a/packages/emitter/lib/decorators.tsp +++ b/packages/emitter/lib/decorators.tsp @@ -162,6 +162,46 @@ extern dec encoding(target: unknown, mime: valueof string); */ extern dec errors(target: unknown, ...errors: unknown[]); +/** + * Forces a model, scalar, or union to be inlined instead of creating a standalone def. + * By default, named types create separate definitions with references. + * Use @inline to expand the type inline at each usage site. + * + * @example Inline model + * ```typespec + * @inline + * model Caption { + * text?: string; + * } + * + * model Main { + * captions?: Caption[]; // Expands inline, no separate "caption" def + * } + * ``` + * + * @example Inline scalar + * ```typespec + * @inline + * @maxLength(50) + * scalar Handle extends string; + * + * model Main { + * handle?: Handle; // Expands to { type: "string", maxLength: 50 } + * } + * ``` + * + * @example Inline union + * ```typespec + * @inline + * union Status { "active", "inactive", string } + * + * model Main { + * status?: Status; // Expands inline with knownValues + * } + * ``` + */ +extern dec inline(target: unknown); + /** * Specifies a default value for a scalar or union definition. * Only valid on standalone scalar or union defs (not @inline). diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index 945d15d..ff7e241 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -641,9 +641,20 @@ export class TypelexEmitter { isClosed(this.program, unionType) ) { const propDesc = prop ? getDoc(this.program, prop) : undefined; - const defaultValue = prop?.defaultValue - ? serializeValueAsJson(this.program, prop.defaultValue, prop) - : undefined; + + // Check for default value: property default takes precedence, then union's @default + let defaultValue: string | number | boolean | undefined; + if (prop?.defaultValue !== undefined) { + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; + } else { + // If no property default, check union's @default decorator + const rawUnionDefault = getDefault(this.program, unionType); + const unionDefault = this.processDefaultValue(rawUnionDefault); + if (unionDefault !== undefined && typeof unionDefault === 'number') { + defaultValue = unionDefault; + } + } + return { type: "integer", enum: variants.numericLiterals, @@ -666,9 +677,31 @@ export class TypelexEmitter { ) { const isClosedUnion = isClosed(this.program, unionType); const propDesc = prop ? getDoc(this.program, prop) : undefined; - const defaultValue = prop?.defaultValue - ? serializeValueAsJson(this.program, prop.defaultValue, prop) - : undefined; + + // Check for default value: property default takes precedence, then union's @default + let defaultValue: string | number | boolean | undefined; + if (prop?.defaultValue !== undefined) { + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; + } else { + // If no property default, check union's @default decorator + const rawUnionDefault = getDefault(this.program, unionType); + const unionDefault = this.processDefaultValue(rawUnionDefault); + + if (unionDefault !== undefined) { + // Check if it's a Type (model reference for tokens) + if (typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { + // Resolve the model reference to its NSID + const tokenModel = unionDefault as Model; + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true + if (tokenRef) { + defaultValue = tokenRef; + } + } else if (typeof unionDefault === 'string') { + defaultValue = unionDefault; + } + } + } + const maxLength = getMaxLength(this.program, unionType); const minLength = getMinLength(this.program, unionType); const maxGraphemes = getMaxGraphemes(this.program, unionType); diff --git a/packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp b/packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp new file mode 100644 index 0000000..eb31469 --- /dev/null +++ b/packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp @@ -0,0 +1,125 @@ +import "@typelex/emitter"; + +namespace community.lexicon.calendar.event { + /** A calendar event. */ + @rec("tid") + model Main { + /** The name of the event. */ + @required + name: string; + + /** The description of the event. */ + description?: string; + + /** Client-declared timestamp when the event was created. */ + @required + createdAt: datetime; + + /** Client-declared timestamp when the event starts. */ + startsAt?: datetime; + + /** Client-declared timestamp when the event ends. */ + endsAt?: datetime; + + /** The attendance mode of the event. */ + mode?: Mode; + + /** The status of the event. */ + status?: Status; + + /** The locations where the event takes place. */ + locations?: ( + | Uri + | community.lexicon.location.address.Main + | community.lexicon.location.fsq.Main + | community.lexicon.location.geo.Main + | community.lexicon.location.hthree.Main + )[]; + + /** URIs associated with the event. */ + uris?: Uri[]; + } + + /** The mode of the event. */ + @default(Inperson) + union Mode { + Hybrid, + Inperson, + Virtual, + string, + } + + /** A virtual event that takes place online. */ + @token + model Virtual {} + + /** An in-person event that takes place offline. */ + @token + model Inperson {} + + /** A hybrid event that takes place both online and offline. */ + @token + model Hybrid {} + + /** The status of the event. */ + @default(Scheduled) + union Status { + Cancelled, + Planned, + Postponed, + Rescheduled, + Scheduled, + string, + } + + /** The event has been created, but not finalized. */ + @token + model Planned {} + + /** The event has been created and scheduled. */ + @token + model Scheduled {} + + /** The event has been rescheduled. */ + @token + model Rescheduled {} + + /** The event has been cancelled. */ + @token + model Cancelled {} + + /** The event has been postponed and a new start date has not been set. */ + @token + model Postponed {} + + /** A URI associated with the event. */ + model Uri { + @required + uri: uri; + + /** The display name of the URI. */ + name?: string; + } +} + +// --- Externals --- + +@external +namespace community.lexicon.location.address { + model Main {} +} + +@external +namespace community.lexicon.location.fsq { + model Main {} +} + +@external +namespace community.lexicon.location.geo { + model Main {} +} + +@external +namespace community.lexicon.location.hthree { + model Main {} +} diff --git a/packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp b/packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp new file mode 100644 index 0000000..1a730e8 --- /dev/null +++ b/packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp @@ -0,0 +1,41 @@ +import "@typelex/emitter"; + +namespace community.lexicon.calendar.rsvp { + /** An RSVP for an event. */ + @rec("tid") + model Main { + @required + subject: `com`.atproto.repo.strongRef.Main; + + @required + status: Status; + } + + @inline + @default(Going) + union Status { + Interested, + Going, + Notgoing, + string, + } + + /** Interested in the event */ + @token + model Interested {} + + /** Going to the event */ + @token + model Going {} + + /** Not going to the event */ + @token + model Notgoing {} +} + +// --- Externals --- + +@external +namespace `com`.atproto.repo.strongRef { + model Main {} +} diff --git a/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json b/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json index b6e7b26..f186d75 100644 --- a/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json +++ b/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json @@ -2,6 +2,75 @@ "lexicon": 1, "id": "community.lexicon.calendar.event", "defs": { + "main": { + "type": "record", + "description": "A calendar event.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "name", + "createdAt" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the event." + }, + "description": { + "type": "string", + "description": "The description of the event." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event was created." + }, + "startsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event starts." + }, + "endsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event ends." + }, + "mode": { + "type": "ref", + "ref": "#mode", + "description": "The attendance mode of the event." + }, + "status": { + "type": "ref", + "ref": "#status", + "description": "The status of the event." + }, + "locations": { + "type": "array", + "description": "The locations where the event takes place.", + "items": { + "type": "union", + "refs": [ + "#uri", + "community.lexicon.location.address", + "community.lexicon.location.fsq", + "community.lexicon.location.geo", + "community.lexicon.location.hthree" + ] + } + }, + "uris": { + "type": "array", + "description": "URIs associated with the event.", + "items": { + "type": "ref", + "ref": "#uri" + } + } + } + } + }, "mode": { "type": "string", "description": "The mode of the event.", @@ -23,6 +92,55 @@ "hybrid": { "type": "token", "description": "A hybrid event that takes place both online and offline." + }, + "status": { + "type": "string", + "description": "The status of the event.", + "default": "community.lexicon.calendar.event#scheduled", + "knownValues": [ + "community.lexicon.calendar.event#cancelled", + "community.lexicon.calendar.event#planned", + "community.lexicon.calendar.event#postponed", + "community.lexicon.calendar.event#rescheduled", + "community.lexicon.calendar.event#scheduled" + ] + }, + "planned": { + "type": "token", + "description": "The event has been created, but not finalized." + }, + "scheduled": { + "type": "token", + "description": "The event has been created and scheduled." + }, + "rescheduled": { + "type": "token", + "description": "The event has been rescheduled." + }, + "cancelled": { + "type": "token", + "description": "The event has been cancelled." + }, + "postponed": { + "type": "token", + "description": "The event has been postponed and a new start date has not been set." + }, + "uri": { + "type": "object", + "description": "A URI associated with the event.", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string", + "description": "The display name of the URI." + } + } } } -} \ No newline at end of file +} diff --git a/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json b/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json index e58a59a..18cf124 100644 --- a/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json +++ b/packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json @@ -42,4 +42,4 @@ "description": "Not going to the event" } } -} \ No newline at end of file +} -- 2.43.0 From 7bd2145b86c9934b492842bede2ed98aa780e639 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 14 Oct 2025 02:49:03 +0100 Subject: [PATCH] fix types --- packages/emitter/src/emitter.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index ff7e241..73f27a0 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -48,6 +48,9 @@ import type { LexCidLink, LexRefVariant, LexToken, + LexBoolean, + LexInteger, + LexString, } from "./types.js"; import { @@ -415,7 +418,7 @@ export class TypelexEmitter { // Apply @default decorator if present const rawDefault = getDefault(this.program, scalar); const defaultValue = this.processDefaultValue(rawDefault); - let defWithDefault: any = { ...scalarDef }; + let defWithDefault: LexObjectProperty = { ...scalarDef }; if (defaultValue !== undefined) { // Check if it's a Type (model reference for tokens) @@ -431,7 +434,14 @@ export class TypelexEmitter { } else { // Validate that the default value matches the type this.assertValidValueForType(scalarDef.type, defaultValue, scalar); - defWithDefault = { ...defWithDefault, default: defaultValue }; + // Type-safe narrowing based on both the type discriminator and value type + if (scalarDef.type === "boolean" && typeof defaultValue === "boolean") { + (defWithDefault as LexBoolean).default = defaultValue; + } else if (scalarDef.type === "integer" && typeof defaultValue === "number") { + (defWithDefault as LexInteger).default = defaultValue; + } else if (scalarDef.type === "string" && typeof defaultValue === "string") { + (defWithDefault as LexString).default = defaultValue; + } } } @@ -439,11 +449,11 @@ export class TypelexEmitter { if (scalarDef.type === "integer") { const minValue = getMinValue(this.program, scalar); if (minValue !== undefined) { - (defWithDefault as any).minimum = minValue; + (defWithDefault as LexInteger).minimum = minValue; } const maxValue = getMaxValue(this.program, scalar); if (maxValue !== undefined) { - (defWithDefault as any).maximum = maxValue; + (defWithDefault as LexInteger).maximum = maxValue; } } @@ -474,7 +484,7 @@ export class TypelexEmitter { // Apply @default decorator if present const rawDefault = getDefault(this.program, union); const defaultValue = this.processDefaultValue(rawDefault); - let defWithDefault: any = { ...unionDef }; + let defWithDefault: LexString = { ...unionDef as LexString }; if (defaultValue !== undefined) { // Check if it's a Type (model reference for tokens) @@ -517,7 +527,7 @@ export class TypelexEmitter { `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, target: union, }); - } else if (unionDef.type === "integer" && (unionDef as any).enum) { + } else if (unionDef.type === "integer" && (unionDef as LexInteger).enum) { // Integer enums can also be defs const defName = name.charAt(0).toLowerCase() + name.slice(1); const description = getDoc(this.program, union); @@ -525,7 +535,7 @@ export class TypelexEmitter { // Apply @default decorator if present const rawDefault = getDefault(this.program, union); const defaultValue = this.processDefaultValue(rawDefault); - let defWithDefault = { ...unionDef }; + let defWithDefault: LexInteger = { ...unionDef as LexInteger }; if (defaultValue !== undefined) { if (typeof defaultValue === "number") { @@ -645,7 +655,7 @@ export class TypelexEmitter { // Check for default value: property default takes precedence, then union's @default let defaultValue: string | number | boolean | undefined; if (prop?.defaultValue !== undefined) { - defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as string | number | boolean; } else { // If no property default, check union's @default decorator const rawUnionDefault = getDefault(this.program, unionType); @@ -681,7 +691,7 @@ export class TypelexEmitter { // Check for default value: property default takes precedence, then union's @default let defaultValue: string | number | boolean | undefined; if (prop?.defaultValue !== undefined) { - defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as string | number | boolean; } else { // If no property default, check union's @default decorator const rawUnionDefault = getDefault(this.program, unionType); @@ -1452,10 +1462,12 @@ export class TypelexEmitter { const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); // For union defaults that are model references, we need to resolve them for comparison - let resolvedUnionDefault: string | number | boolean | undefined = unionDefault as any; + let resolvedUnionDefault: string | number | boolean | undefined; if (unionDefault && typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { const ref = this.getModelReference(unionDefault as Model, true); resolvedUnionDefault = ref || undefined; + } else { + resolvedUnionDefault = unionDefault as string | number | boolean; } // If the union has a different default, or if the property has a default but the union doesn't, error -- 2.43.0