An experimental TypeSpec syntax for Lexicon

[Breaking] Add @default decorator, uninline scalars by default #5

merged opened by danabra.mov targeting main from feat/scalar-defs-and-default
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/sh.tangled.repo.pull/3m34kyhlkgs22
+1111 -31
Diff #0
+65
packages/emitter/lib/decorators.tsp
··· 163 163 extern dec errors(target: unknown, ...errors: unknown[]); 164 164 165 165 /** 166 + * Forces a model, scalar, or union to be inlined instead of creating a standalone def. 167 + * By default, named types create separate definitions with references. 168 + * Use @inline to expand the type inline at each usage site. 169 + * 170 + * @example Inline model 171 + * ```typespec 172 + * @inline 173 + * model Caption { 174 + * text?: string; 175 + * } 176 + * 177 + * model Main { 178 + * captions?: Caption[]; // Expands inline, no separate "caption" def 179 + * } 180 + * ``` 181 + * 182 + * @example Inline scalar 183 + * ```typespec 184 + * @inline 185 + * @maxLength(50) 186 + * scalar Handle extends string; 187 + * 188 + * model Main { 189 + * handle?: Handle; // Expands to { type: "string", maxLength: 50 } 190 + * } 191 + * ``` 192 + * 193 + * @example Inline union 194 + * ```typespec 195 + * @inline 196 + * union Status { "active", "inactive", string } 197 + * 198 + * model Main { 199 + * status?: Status; // Expands inline with knownValues 200 + * } 201 + * ``` 202 + */ 203 + extern dec inline(target: unknown); 204 + 205 + /** 206 + * Specifies a default value for a scalar or union definition. 207 + * Only valid on standalone scalar or union defs (not @inline). 208 + * The value must match the underlying type (string, integer, or boolean). 209 + * For unions with token refs, you can pass a model reference directly. 210 + * 211 + * @param value - The default value (literal or model reference for tokens) 212 + * 213 + * @example Scalar with default 214 + * ```typespec 215 + * @default("standard") 216 + * scalar Mode extends string; 217 + * ``` 218 + * 219 + * @example Union with token default 220 + * ```typespec 221 + * @default(Inperson) 222 + * union EventMode { Hybrid, Inperson, Virtual, string } 223 + * 224 + * @token 225 + * model Inperson {} 226 + * ``` 227 + */ 228 + extern dec `default`(target: unknown, value: unknown); 229 + 230 + /** 166 231 * Marks a namespace as external, preventing it from emitting JSON output. 167 232 * This decorator can only be applied to namespaces.
+17
packages/emitter/src/decorators.ts
··· 25 25 const maxBytesKey = Symbol("maxBytes"); 26 26 const minBytesKey = Symbol("minBytes"); 27 27 const externalKey = Symbol("external"); 28 + const defaultKey = Symbol("default"); 28 29 29 30 /** 30 31 * @maxBytes decorator for maximum length of bytes type ··· 296 297 return program.stateSet(readOnlyKey).has(target); 297 298 } 298 299 300 + /** 301 + * @default decorator for setting default values on scalars and unions 302 + * The value can be a literal (string, number, boolean) or a model reference for tokens 303 + */ 304 + export function $default(context: DecoratorContext, target: Type, value: any) { 305 + // Just store the raw value - let the emitter handle unwrapping and validation 306 + context.program.stateMap(defaultKey).set(target, value); 307 + } 308 + 309 + export function getDefault( 310 + program: Program, 311 + target: Type, 312 + ): any | undefined { 313 + return program.stateMap(defaultKey).get(target); 314 + } 315 + 299 316 /** 300 317 * @external decorator for marking a namespace as external 301 318 * External namespaces are skipped during emission and don't produce JSON files
+280 -28
packages/emitter/src/emitter.ts
··· 68 68 getMaxBytes, 69 69 getMinBytes, 70 70 isExternal, 71 + getDefault, 71 72 } from "./decorators.js"; 72 73 73 74 export interface EmitterOptions { ··· 98 99 private options: EmitterOptions, 99 100 ) {} 100 101 102 + /** 103 + * Process the raw default value from the decorator, unwrapping TypeSpec value objects 104 + * and returning either a primitive (string, number, boolean) or a Type (for model references) 105 + */ 106 + private processDefaultValue(rawValue: any): string | number | boolean | Type | undefined { 107 + if (rawValue === undefined) return undefined; 108 + 109 + // TypeSpec may wrap values - check if this is a value object first 110 + if (rawValue && typeof rawValue === 'object' && rawValue.valueKind) { 111 + if (rawValue.valueKind === "StringValue") { 112 + return rawValue.value; 113 + } else if (rawValue.valueKind === "NumericValue" || rawValue.valueKind === "NumberValue") { 114 + return rawValue.value; 115 + } else if (rawValue.valueKind === "BooleanValue") { 116 + return rawValue.value; 117 + } 118 + return undefined; // Unsupported valueKind 119 + } 120 + 121 + // Check if it's a Type object (Model, String, Number, Boolean literals) 122 + if (rawValue && typeof rawValue === 'object' && rawValue.kind) { 123 + if (rawValue.kind === "String") { 124 + return (rawValue as StringLiteral).value; 125 + } else if (rawValue.kind === "Number") { 126 + return (rawValue as NumericLiteral).value; 127 + } else if (rawValue.kind === "Boolean") { 128 + return (rawValue as BooleanLiteral).value; 129 + } else if (rawValue.kind === "Model") { 130 + // Return the model itself for token references 131 + return rawValue as Model; 132 + } 133 + return undefined; // Unsupported kind 134 + } 135 + 136 + // Direct primitive value 137 + if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') { 138 + return rawValue; 139 + } 140 + 141 + return undefined; 142 + } 143 + 101 144 async emit() { 102 145 const globalNs = this.program.getGlobalNamespaceType(); 103 146 ··· 356 399 } 357 400 358 401 private addScalarToDefs(lexicon: LexiconDoc, scalar: Scalar) { 402 + // Only skip if the scalar itself is in TypeSpec namespace (built-in scalars) 359 403 if (scalar.namespace?.name === "TypeSpec") return; 360 - if (scalar.baseScalar?.namespace?.name === "TypeSpec") return; 361 404 362 405 // Skip @inline scalars - they should be inlined, not defined separately 363 406 if (isInline(this.program, scalar)) { ··· 368 411 const scalarDef = this.scalarToLexiconPrimitive(scalar, undefined); 369 412 if (scalarDef) { 370 413 const description = getDoc(this.program, scalar); 371 - lexicon.defs[defName] = { ...scalarDef, description } as LexUserType; 414 + 415 + // Apply @default decorator if present 416 + const rawDefault = getDefault(this.program, scalar); 417 + const defaultValue = this.processDefaultValue(rawDefault); 418 + let defWithDefault: any = { ...scalarDef }; 419 + 420 + if (defaultValue !== undefined) { 421 + // Check if it's a Type (model reference for tokens) 422 + if (typeof defaultValue === 'object' && 'kind' in defaultValue) { 423 + // For model references, we need to resolve to NSID 424 + // This shouldn't happen for scalars, only unions support token refs 425 + this.program.reportDiagnostic({ 426 + code: "invalid-default-on-scalar", 427 + severity: "error", 428 + message: "@default on scalars must be a literal value (string, number, or boolean), not a model reference", 429 + target: scalar, 430 + }); 431 + } else { 432 + // Validate that the default value matches the type 433 + this.assertValidValueForType(scalarDef.type, defaultValue, scalar); 434 + defWithDefault = { ...defWithDefault, default: defaultValue }; 435 + } 436 + } 437 + 438 + // Apply integer constraints for standalone scalar defs 439 + if (scalarDef.type === "integer") { 440 + const minValue = getMinValue(this.program, scalar); 441 + if (minValue !== undefined) { 442 + (defWithDefault as any).minimum = minValue; 443 + } 444 + const maxValue = getMaxValue(this.program, scalar); 445 + if (maxValue !== undefined) { 446 + (defWithDefault as any).maximum = maxValue; 447 + } 448 + } 449 + 450 + lexicon.defs[defName] = { ...defWithDefault, description } as LexUserType; 372 451 } 373 452 } 374 453 ··· 391 470 if (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) { 392 471 const defName = name.charAt(0).toLowerCase() + name.slice(1); 393 472 const description = getDoc(this.program, union); 394 - lexicon.defs[defName] = { ...unionDef, description }; 473 + 474 + // Apply @default decorator if present 475 + const rawDefault = getDefault(this.program, union); 476 + const defaultValue = this.processDefaultValue(rawDefault); 477 + let defWithDefault: any = { ...unionDef }; 478 + 479 + if (defaultValue !== undefined) { 480 + // Check if it's a Type (model reference for tokens) 481 + if (typeof defaultValue === 'object' && 'kind' in defaultValue) { 482 + // Resolve the model reference to its NSID 483 + const tokenModel = defaultValue as Model; 484 + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true 485 + if (tokenRef) { 486 + defWithDefault = { ...defWithDefault, default: tokenRef }; 487 + } else { 488 + this.program.reportDiagnostic({ 489 + code: "invalid-default-token", 490 + severity: "error", 491 + message: "@default value must be a valid token model reference", 492 + target: union, 493 + }); 494 + } 495 + } else { 496 + // Literal value - validate it matches the union type 497 + if (typeof defaultValue !== "string") { 498 + this.program.reportDiagnostic({ 499 + code: "invalid-default-value-type", 500 + severity: "error", 501 + message: `Default value type mismatch: expected string, got ${typeof defaultValue}`, 502 + target: union, 503 + }); 504 + } else { 505 + defWithDefault = { ...defWithDefault, default: defaultValue }; 506 + } 507 + } 508 + } 509 + 510 + lexicon.defs[defName] = { ...defWithDefault, description }; 395 511 } else if (unionDef.type === "union") { 396 512 this.program.reportDiagnostic({ 397 513 code: "union-refs-not-allowed-as-def", ··· 401 517 `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, 402 518 target: union, 403 519 }); 404 - } 405 - } 520 + } else if (unionDef.type === "integer" && (unionDef as any).enum) { 521 + // Integer enums can also be defs 522 + const defName = name.charAt(0).toLowerCase() + name.slice(1); 523 + const description = getDoc(this.program, union); 406 524 525 + // Apply @default decorator if present 526 + const rawDefault = getDefault(this.program, union); 527 + const defaultValue = this.processDefaultValue(rawDefault); 528 + let defWithDefault = { ...unionDef }; 407 529 530 + if (defaultValue !== undefined) { 531 + if (typeof defaultValue === "number") { 532 + defWithDefault = { ...defWithDefault, default: defaultValue }; 533 + } else { 534 + this.program.reportDiagnostic({ 535 + code: "invalid-default-value-type", 536 + severity: "error", 537 + message: `Default value type mismatch: expected integer, got ${typeof defaultValue}`, 538 + target: union, 539 + }); 540 + } 541 + } 408 542 543 + lexicon.defs[defName] = { ...defWithDefault, description }; 544 + } 545 + } 409 546 410 547 411 548 ··· 501 638 502 639 503 640 641 + isClosed(this.program, unionType) 642 + ) { 643 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 504 644 645 + // Check for default value: property default takes precedence, then union's @default 646 + let defaultValue: string | number | boolean | undefined; 647 + if (prop?.defaultValue !== undefined) { 648 + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; 649 + } else { 650 + // If no property default, check union's @default decorator 651 + const rawUnionDefault = getDefault(this.program, unionType); 652 + const unionDefault = this.processDefaultValue(rawUnionDefault); 653 + if (unionDefault !== undefined && typeof unionDefault === 'number') { 654 + defaultValue = unionDefault; 655 + } 656 + } 505 657 658 + return { 659 + type: "integer", 660 + enum: variants.numericLiterals, 506 661 507 662 508 663 ··· 519 674 520 675 521 676 677 + ) { 678 + const isClosedUnion = isClosed(this.program, unionType); 679 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 522 680 681 + // Check for default value: property default takes precedence, then union's @default 682 + let defaultValue: string | number | boolean | undefined; 683 + if (prop?.defaultValue !== undefined) { 684 + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; 685 + } else { 686 + // If no property default, check union's @default decorator 687 + const rawUnionDefault = getDefault(this.program, unionType); 688 + const unionDefault = this.processDefaultValue(rawUnionDefault); 523 689 690 + if (unionDefault !== undefined) { 691 + // Check if it's a Type (model reference for tokens) 692 + if (typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { 693 + // Resolve the model reference to its NSID 694 + const tokenModel = unionDefault as Model; 695 + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true 696 + if (tokenRef) { 697 + defaultValue = tokenRef; 698 + } 699 + } else if (typeof unionDefault === 'string') { 700 + defaultValue = unionDefault; 701 + } 702 + } 703 + } 524 704 705 + const maxLength = getMaxLength(this.program, unionType); 706 + const minLength = getMinLength(this.program, unionType); 707 + const maxGraphemes = getMaxGraphemes(this.program, unionType); 525 708 526 709 527 710 ··· 1145 1328 1146 1329 1147 1330 1148 - 1149 - 1150 - 1151 - 1152 - 1153 - 1154 - 1155 - 1156 - 1157 - 1158 1331 prop?: ModelProperty, 1159 1332 propDesc?: string, 1160 1333 ): LexObjectProperty | null { 1334 + // Check if this scalar should be referenced instead of inlined 1335 + const scalarRef = this.getScalarReference(scalar); 1336 + if (scalarRef) { 1337 + // Check if property has a default value that would conflict with the scalar's @default 1338 + if (prop?.defaultValue !== undefined) { 1339 + const scalarDefaultRaw = getDefault(this.program, scalar); 1340 + const scalarDefault = this.processDefaultValue(scalarDefaultRaw); 1341 + const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); 1342 + 1343 + // If the scalar has a different default, or if the property has a default but the scalar doesn't, error 1344 + if (scalarDefault !== propDefault) { 1345 + this.program.reportDiagnostic({ 1346 + code: "conflicting-defaults", 1347 + severity: "error", 1348 + message: scalarDefault !== undefined 1349 + ? `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.` 1350 + : `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.`, 1351 + target: prop, 1352 + }); 1353 + } 1354 + } 1355 + 1356 + return { type: "ref" as const, ref: scalarRef, description: propDesc }; 1357 + } 1358 + 1359 + // Inline the scalar 1161 1360 const primitive = this.scalarToLexiconPrimitive(scalar, prop); 1162 1361 if (!primitive) return null; 1163 1362 ··· 1246 1445 if (!isDefining) { 1247 1446 const unionRef = this.getUnionReference(unionType); 1248 1447 if (unionRef) { 1448 + // Check if property has a default value that would conflict with the union's @default 1449 + if (prop?.defaultValue !== undefined) { 1450 + const unionDefaultRaw = getDefault(this.program, unionType); 1451 + const unionDefault = this.processDefaultValue(unionDefaultRaw); 1452 + const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); 1453 + 1454 + // For union defaults that are model references, we need to resolve them for comparison 1455 + let resolvedUnionDefault: string | number | boolean | undefined = unionDefault as any; 1456 + if (unionDefault && typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { 1457 + const ref = this.getModelReference(unionDefault as Model, true); 1458 + resolvedUnionDefault = ref || undefined; 1459 + } 1460 + 1461 + // If the union has a different default, or if the property has a default but the union doesn't, error 1462 + if (resolvedUnionDefault !== propDefault) { 1463 + this.program.reportDiagnostic({ 1464 + code: "conflicting-defaults", 1465 + severity: "error", 1466 + message: unionDefault !== undefined 1467 + ? `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.` 1468 + : `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.`, 1469 + target: prop, 1470 + }); 1471 + } 1472 + } 1473 + 1249 1474 return { type: "ref" as const, ref: unionRef, description: propDesc }; 1250 1475 } 1251 1476 } ··· 1271 1496 // Check if this scalar (or its base) is bytes type 1272 1497 if (this.isScalarOfType(scalar, "bytes")) { 1273 1498 const byteDef: LexBytes = { type: "bytes" }; 1274 - const target = prop || scalar; 1275 1499 1276 - const minLength = getMinBytes(this.program, target); 1500 + // Check scalar first for its own constraints, then property overrides 1501 + const minLength = getMinBytes(this.program, scalar) ?? (prop ? getMinBytes(this.program, prop) : undefined); 1277 1502 if (minLength !== undefined) { 1278 1503 byteDef.minLength = minLength; 1279 1504 } 1280 1505 1281 - const maxLength = getMaxBytes(this.program, target); 1506 + const maxLength = getMaxBytes(this.program, scalar) ?? (prop ? getMaxBytes(this.program, prop) : undefined); 1282 1507 if (maxLength !== undefined) { 1283 1508 byteDef.maxLength = maxLength; 1284 1509 } ··· 1310 1535 1311 1536 // Apply string constraints 1312 1537 if (primitive.type === "string") { 1313 - const target = prop || scalar; 1314 - const maxLength = getMaxLength(this.program, target); 1538 + // Check scalar first for its own constraints, then property overrides 1539 + const maxLength = getMaxLength(this.program, scalar) ?? (prop ? getMaxLength(this.program, prop) : undefined); 1315 1540 if (maxLength !== undefined) { 1316 1541 primitive.maxLength = maxLength; 1317 1542 } 1318 - const minLength = getMinLength(this.program, target); 1543 + const minLength = getMinLength(this.program, scalar) ?? (prop ? getMinLength(this.program, prop) : undefined); 1319 1544 if (minLength !== undefined) { 1320 1545 primitive.minLength = minLength; 1321 1546 } 1322 - const maxGraphemes = getMaxGraphemes(this.program, target); 1547 + const maxGraphemes = getMaxGraphemes(this.program, scalar) ?? (prop ? getMaxGraphemes(this.program, prop) : undefined); 1323 1548 if (maxGraphemes !== undefined) { 1324 1549 primitive.maxGraphemes = maxGraphemes; 1325 1550 } 1326 - const minGraphemes = getMinGraphemes(this.program, target); 1551 + const minGraphemes = getMinGraphemes(this.program, scalar) ?? (prop ? getMinGraphemes(this.program, prop) : undefined); 1327 1552 if (minGraphemes !== undefined) { 1328 1553 primitive.minGraphemes = minGraphemes; 1329 1554 } 1330 1555 } 1331 1556 1332 1557 // Apply numeric constraints 1333 - if (prop && primitive.type === "integer") { 1334 - const minValue = getMinValue(this.program, prop); 1558 + if (primitive.type === "integer") { 1559 + // Check scalar first for its own constraints, then property overrides 1560 + const minValue = getMinValue(this.program, scalar) ?? (prop ? getMinValue(this.program, prop) : undefined); 1335 1561 if (minValue !== undefined) { 1336 1562 primitive.minimum = minValue; 1337 1563 } 1338 - const maxValue = getMaxValue(this.program, prop); 1564 + const maxValue = getMaxValue(this.program, scalar) ?? (prop ? getMaxValue(this.program, prop) : undefined); 1339 1565 if (maxValue !== undefined) { 1340 1566 primitive.maximum = maxValue; 1341 1567 } ··· 1431 1657 private assertValidValueForType( 1432 1658 primitiveType: string, 1433 1659 value: unknown, 1434 - prop: ModelProperty, 1660 + target: ModelProperty | Scalar | Union, 1435 1661 ): void { 1436 1662 const valid = 1437 1663 (primitiveType === "boolean" && typeof value === "boolean") || ··· 1442 1668 code: "invalid-default-value-type", 1443 1669 severity: "error", 1444 1670 message: `Default value type mismatch: expected ${primitiveType}, got ${typeof value}`, 1445 - target: prop, 1671 + target: target, 1446 1672 }); 1447 1673 } 1448 1674 } ··· 1507 1733 1508 1734 1509 1735 return this.getReference(union, union.name, union.namespace); 1736 + } 1737 + 1738 + private getScalarReference(scalar: Scalar): string | null { 1739 + // Built-in TypeSpec scalars (string, integer, boolean themselves) should not be referenced 1740 + if (scalar.namespace?.name === "TypeSpec") return null; 1741 + 1742 + // @inline scalars should be inlined, not referenced 1743 + if (isInline(this.program, scalar)) return null; 1744 + 1745 + // Scalars without names or namespace can't be referenced 1746 + if (!scalar.name || !scalar.namespace) return null; 1747 + 1748 + const defName = scalar.name.charAt(0).toLowerCase() + scalar.name.slice(1); 1749 + const namespaceName = getNamespaceFullName(scalar.namespace); 1750 + if (!namespaceName) return null; 1751 + 1752 + // Local reference (same namespace) - use short ref 1753 + if ( 1754 + this.currentLexiconId === namespaceName || 1755 + this.currentLexiconId === `${namespaceName}.defs` 1756 + ) { 1757 + return `#${defName}`; 1758 + } 1759 + 1760 + // Cross-namespace reference 1761 + return `${namespaceName}#${defName}`; 1510 1762 } 1511 1763 1512 1764 private modelToLexiconArray(
+2
packages/emitter/src/tsp-index.ts
··· 15 15 $maxBytes, 16 16 $minBytes, 17 17 $external, 18 + $default, 18 19 } from "./decorators.js"; 19 20 20 21 /** @internal */ ··· 36 37 maxBytes: $maxBytes, 37 38 minBytes: $minBytes, 38 39 external: $external, 40 + default: $default, 39 41 }, 40 42 };
+2
packages/emitter/test/integration/atproto/input/app/bsky/actor/defs.tsp
··· 232 232 prioritizeFollowedUsers?: boolean; 233 233 } 234 234 235 + @inline 235 236 @maxLength(640) 236 237 @maxGraphemes(64) 237 238 scalar InterestTag extends string; ··· 292 293 @required did: did; 293 294 } 294 295 296 + @inline 295 297 @maxLength(100) 296 298 scalar NudgeToken extends string; 297 299
+30
packages/emitter/test/spec/basic/input/com/example/scalarDefaults.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.example.scalarDefaults { 4 + /** Test default decorator on scalars */ 5 + model Main { 6 + /** Uses string scalar with default */ 7 + mode?: Mode; 8 + 9 + /** Uses integer scalar with default */ 10 + limit?: Limit; 11 + 12 + /** Uses boolean scalar with default */ 13 + enabled?: Enabled; 14 + } 15 + 16 + /** A string type with a default value */ 17 + @default("standard") 18 + @maxLength(50) 19 + scalar Mode extends string; 20 + 21 + /** An integer type with a default value */ 22 + @default(50) 23 + @minValue(1) 24 + @maxValue(100) 25 + scalar Limit extends integer; 26 + 27 + /** A boolean type with a default value */ 28 + @default(true) 29 + scalar Enabled extends boolean; 30 + }
+22
packages/emitter/test/spec/basic/input/com/example/scalarDefs.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.example.scalarDefs { 4 + /** Scalar defs should create standalone defs like models and unions */ 5 + model Main { 6 + /** Uses a custom string scalar with constraints */ 7 + tag?: Tag; 8 + 9 + /** Uses a custom integer scalar with constraints */ 10 + count?: Count; 11 + } 12 + 13 + /** A custom string type with length constraints */ 14 + @maxLength(100) 15 + @maxGraphemes(50) 16 + scalar Tag extends string; 17 + 18 + /** A custom integer type with value constraints */ 19 + @minValue(1) 20 + @maxValue(100) 21 + scalar Count extends integer; 22 + }
+22
packages/emitter/test/spec/basic/input/com/example/scalarInline.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.example.scalarInline { 4 + /** Test inline decorator on scalars */ 5 + model Main { 6 + /** Inline scalar - should not create a def */ 7 + tag?: Tag; 8 + 9 + /** Non-inline scalar - should create a def */ 10 + category?: Category; 11 + } 12 + 13 + /** An inline scalar should be inlined at usage sites */ 14 + @inline 15 + @maxLength(50) 16 + @maxGraphemes(25) 17 + scalar Tag extends string; 18 + 19 + /** A regular scalar should create a standalone def */ 20 + @maxLength(100) 21 + scalar Category extends string; 22 + }
+53
packages/emitter/test/spec/basic/input/com/example/unionDefaults.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.example.unionDefaults { 4 + /** Test default decorator on unions */ 5 + model Main { 6 + /** Union with token refs and default */ 7 + eventMode?: EventMode; 8 + 9 + /** Union with string literals and default */ 10 + sortOrder?: SortOrder; 11 + 12 + /** Union with integer literals and default */ 13 + priority?: Priority; 14 + } 15 + 16 + /** Union of tokens with default pointing to a token */ 17 + @default(Inperson) 18 + union EventMode { 19 + Hybrid, 20 + Inperson, 21 + Virtual, 22 + string, 23 + } 24 + 25 + /** A hybrid event */ 26 + @token 27 + model Hybrid {} 28 + 29 + /** An in-person event */ 30 + @token 31 + model Inperson {} 32 + 33 + /** A virtual event */ 34 + @token 35 + model Virtual {} 36 + 37 + /** Union of string literals with default */ 38 + @default("asc") 39 + union SortOrder { 40 + "asc", 41 + "desc", 42 + string, 43 + } 44 + 45 + /** Union of integer literals with default (closed enum) */ 46 + @default(1) 47 + @closed 48 + union Priority { 49 + 1, 50 + 2, 51 + 3, 52 + } 53 + }
+45
packages/emitter/test/spec/basic/output/com/example/scalarDefaults.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.scalarDefaults", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "mode": { 9 + "type": "ref", 10 + "ref": "#mode", 11 + "description": "Uses string scalar with default" 12 + }, 13 + "limit": { 14 + "type": "ref", 15 + "ref": "#limit", 16 + "description": "Uses integer scalar with default" 17 + }, 18 + "enabled": { 19 + "type": "ref", 20 + "ref": "#enabled", 21 + "description": "Uses boolean scalar with default" 22 + } 23 + }, 24 + "description": "Test default decorator on scalars" 25 + }, 26 + "mode": { 27 + "type": "string", 28 + "maxLength": 50, 29 + "default": "standard", 30 + "description": "A string type with a default value" 31 + }, 32 + "limit": { 33 + "type": "integer", 34 + "minimum": 1, 35 + "maximum": 100, 36 + "default": 50, 37 + "description": "An integer type with a default value" 38 + }, 39 + "enabled": { 40 + "type": "boolean", 41 + "default": true, 42 + "description": "A boolean type with a default value" 43 + } 44 + } 45 + }
+34
packages/emitter/test/spec/basic/output/com/example/scalarDefs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.scalarDefs", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "tag": { 9 + "type": "ref", 10 + "ref": "#tag", 11 + "description": "Uses a custom string scalar with constraints" 12 + }, 13 + "count": { 14 + "type": "ref", 15 + "ref": "#count", 16 + "description": "Uses a custom integer scalar with constraints" 17 + } 18 + }, 19 + "description": "Scalar defs should create standalone defs like models and unions" 20 + }, 21 + "tag": { 22 + "type": "string", 23 + "maxLength": 100, 24 + "maxGraphemes": 50, 25 + "description": "A custom string type with length constraints" 26 + }, 27 + "count": { 28 + "type": "integer", 29 + "minimum": 1, 30 + "maximum": 100, 31 + "description": "A custom integer type with value constraints" 32 + } 33 + } 34 + }
+28
packages/emitter/test/spec/basic/output/com/example/scalarInline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.scalarInline", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "tag": { 9 + "type": "string", 10 + "maxLength": 50, 11 + "maxGraphemes": 25, 12 + "description": "Inline scalar - should not create a def" 13 + }, 14 + "category": { 15 + "type": "ref", 16 + "ref": "#category", 17 + "description": "Non-inline scalar - should create a def" 18 + } 19 + }, 20 + "description": "Test inline decorator on scalars" 21 + }, 22 + "category": { 23 + "type": "string", 24 + "maxLength": 100, 25 + "description": "A regular scalar should create a standalone def" 26 + } 27 + } 28 + }
+61
packages/emitter/test/spec/basic/output/com/example/unionDefaults.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.unionDefaults", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "eventMode": { 9 + "type": "ref", 10 + "ref": "#eventMode", 11 + "description": "Union with token refs and default" 12 + }, 13 + "sortOrder": { 14 + "type": "ref", 15 + "ref": "#sortOrder", 16 + "description": "Union with string literals and default" 17 + }, 18 + "priority": { 19 + "type": "ref", 20 + "ref": "#priority", 21 + "description": "Union with integer literals and default" 22 + } 23 + }, 24 + "description": "Test default decorator on unions" 25 + }, 26 + "eventMode": { 27 + "type": "string", 28 + "knownValues": [ 29 + "com.example.unionDefaults#hybrid", 30 + "com.example.unionDefaults#inperson", 31 + "com.example.unionDefaults#virtual" 32 + ], 33 + "default": "com.example.unionDefaults#inperson", 34 + "description": "Union of tokens with default pointing to a token" 35 + }, 36 + "hybrid": { 37 + "type": "token", 38 + "description": "A hybrid event" 39 + }, 40 + "inperson": { 41 + "type": "token", 42 + "description": "An in-person event" 43 + }, 44 + "virtual": { 45 + "type": "token", 46 + "description": "A virtual event" 47 + }, 48 + "sortOrder": { 49 + "type": "string", 50 + "knownValues": ["asc", "desc"], 51 + "default": "asc", 52 + "description": "Union of string literals with default" 53 + }, 54 + "priority": { 55 + "type": "integer", 56 + "enum": [1, 2, 3], 57 + "default": 1, 58 + "description": "Union of integer literals with default (closed enum)" 59 + } 60 + } 61 + }
+164 -1
DOCS.md
··· 312 312 313 313 Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. 314 314 315 + ### Scalars 316 + 317 + TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models): 318 + 319 + ```typescript 320 + import "@typelex/emitter"; 321 + 322 + namespace com.example { 323 + model Main { 324 + handle?: Handle; 325 + bio?: Bio; 326 + } 327 + 328 + @maxLength(50) 329 + scalar Handle extends string; 330 + 331 + @maxLength(256) 332 + @maxGraphemes(128) 333 + scalar Bio extends string; 334 + } 335 + ``` 336 + 337 + This creates three defs: `main`, `handle`, and `bio`: 338 + 339 + ```json 340 + { 341 + "id": "com.example", 342 + "defs": { 343 + "main": { 344 + "type": "object", 345 + "properties": { 346 + "handle": { "type": "ref", "ref": "#handle" }, 347 + "bio": { "type": "ref", "ref": "#bio" } 348 + } 349 + }, 350 + "handle": { 351 + "type": "string", 352 + "maxLength": 50 353 + }, 354 + "bio": { 355 + "type": "string", 356 + "maxLength": 256, 357 + "maxGraphemes": 128 358 + } 359 + } 360 + } 361 + ``` 362 + 363 + Use `@inline` to expand a scalar inline instead: 364 + 365 + ```typescript 366 + import "@typelex/emitter"; 367 + 368 + namespace com.example { 369 + model Main { 370 + handle?: Handle; 371 + } 372 + 373 + @inline 374 + @maxLength(50) 375 + scalar Handle extends string; 376 + } 377 + ``` 378 + 379 + Now `Handle` is expanded inline (no separate def): 380 + 381 + ```json 382 + // ... 383 + "properties": { 384 + "handle": { "type": "string", "maxLength": 50 } 385 + } 386 + // ... 387 + ``` 388 + 315 389 ## Top-Level Lexicon Types 316 390 317 391 TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. ··· 905 979 906 980 ## Defaults and Constants 907 981 908 - ### Defaults 982 + ### Property Defaults 983 + 984 + You can set default values on properties: 909 985 910 986 ```typescript 911 987 import "@typelex/emitter"; ··· 920 996 921 997 Maps to: `{"default": 1}`, `{"default": "en"}` 922 998 999 + ### Type Defaults 1000 + 1001 + You can also set defaults on scalar and union types using the `@default` decorator: 1002 + 1003 + ```typescript 1004 + import "@typelex/emitter"; 1005 + 1006 + namespace com.example { 1007 + model Main { 1008 + mode?: Mode; 1009 + priority?: Priority; 1010 + } 1011 + 1012 + @default("standard") 1013 + scalar Mode extends string; 1014 + 1015 + @default(1) 1016 + @closed 1017 + @inline 1018 + union Priority { 1, 2, 3 } 1019 + } 1020 + ``` 1021 + 1022 + This creates a default on the type definition itself: 1023 + 1024 + ```json 1025 + { 1026 + "defs": { 1027 + "mode": { 1028 + "type": "string", 1029 + "default": "standard" 1030 + } 1031 + } 1032 + } 1033 + ``` 1034 + 1035 + For unions with token references, pass the model directly: 1036 + 1037 + ```typescript 1038 + import "@typelex/emitter"; 1039 + 1040 + namespace com.example { 1041 + model Main { 1042 + eventType?: EventType; 1043 + } 1044 + 1045 + @default(InPerson) 1046 + union EventType { Hybrid, InPerson, Virtual, string } 1047 + 1048 + @token model Hybrid {} 1049 + @token model InPerson {} 1050 + @token model Virtual {} 1051 + } 1052 + ``` 1053 + 1054 + This resolves to the fully-qualified token NSID: 1055 + 1056 + ```json 1057 + { 1058 + "eventType": { 1059 + "type": "string", 1060 + "knownValues": [ 1061 + "com.example#hybrid", 1062 + "com.example#inPerson", 1063 + "com.example#virtual" 1064 + ], 1065 + "default": "com.example#inPerson" 1066 + } 1067 + } 1068 + ``` 1069 + 1070 + **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: 1071 + 1072 + ```typescript 1073 + @default("standard") 1074 + scalar Mode extends string; 1075 + 1076 + model Main { 1077 + mode?: Mode = "custom"; // ERROR: Conflicting defaults! 1078 + } 1079 + ``` 1080 + 1081 + Solutions: 1082 + 1. Make the defaults match: `mode?: Mode = "standard"` 1083 + 2. Mark the type `@inline`: Allows property-level defaults 1084 + 3. Remove the property default: Uses the type's default 1085 + 923 1086 ### Constants 924 1087 925 1088 Use `@readOnly` with a default:
+125
packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace community.lexicon.calendar.event { 4 + /** A calendar event. */ 5 + @rec("tid") 6 + model Main { 7 + /** The name of the event. */ 8 + @required 9 + name: string; 10 + 11 + /** The description of the event. */ 12 + description?: string; 13 + 14 + /** Client-declared timestamp when the event was created. */ 15 + @required 16 + createdAt: datetime; 17 + 18 + /** Client-declared timestamp when the event starts. */ 19 + startsAt?: datetime; 20 + 21 + /** Client-declared timestamp when the event ends. */ 22 + endsAt?: datetime; 23 + 24 + /** The attendance mode of the event. */ 25 + mode?: Mode; 26 + 27 + /** The status of the event. */ 28 + status?: Status; 29 + 30 + /** The locations where the event takes place. */ 31 + locations?: ( 32 + | Uri 33 + | community.lexicon.location.address.Main 34 + | community.lexicon.location.fsq.Main 35 + | community.lexicon.location.geo.Main 36 + | community.lexicon.location.hthree.Main 37 + )[]; 38 + 39 + /** URIs associated with the event. */ 40 + uris?: Uri[]; 41 + } 42 + 43 + /** The mode of the event. */ 44 + @default(Inperson) 45 + union Mode { 46 + Hybrid, 47 + Inperson, 48 + Virtual, 49 + string, 50 + } 51 + 52 + /** A virtual event that takes place online. */ 53 + @token 54 + model Virtual {} 55 + 56 + /** An in-person event that takes place offline. */ 57 + @token 58 + model Inperson {} 59 + 60 + /** A hybrid event that takes place both online and offline. */ 61 + @token 62 + model Hybrid {} 63 + 64 + /** The status of the event. */ 65 + @default(Scheduled) 66 + union Status { 67 + Cancelled, 68 + Planned, 69 + Postponed, 70 + Rescheduled, 71 + Scheduled, 72 + string, 73 + } 74 + 75 + /** The event has been created, but not finalized. */ 76 + @token 77 + model Planned {} 78 + 79 + /** The event has been created and scheduled. */ 80 + @token 81 + model Scheduled {} 82 + 83 + /** The event has been rescheduled. */ 84 + @token 85 + model Rescheduled {} 86 + 87 + /** The event has been cancelled. */ 88 + @token 89 + model Cancelled {} 90 + 91 + /** The event has been postponed and a new start date has not been set. */ 92 + @token 93 + model Postponed {} 94 + 95 + /** A URI associated with the event. */ 96 + model Uri { 97 + @required 98 + uri: uri; 99 + 100 + /** The display name of the URI. */ 101 + name?: string; 102 + } 103 + } 104 + 105 + // --- Externals --- 106 + 107 + @external 108 + namespace community.lexicon.location.address { 109 + model Main {} 110 + } 111 + 112 + @external 113 + namespace community.lexicon.location.fsq { 114 + model Main {} 115 + } 116 + 117 + @external 118 + namespace community.lexicon.location.geo { 119 + model Main {} 120 + } 121 + 122 + @external 123 + namespace community.lexicon.location.hthree { 124 + model Main {} 125 + }
+41
packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace community.lexicon.calendar.rsvp { 4 + /** An RSVP for an event. */ 5 + @rec("tid") 6 + model Main { 7 + @required 8 + subject: `com`.atproto.repo.strongRef.Main; 9 + 10 + @required 11 + status: Status; 12 + } 13 + 14 + @inline 15 + @default(Going) 16 + union Status { 17 + Interested, 18 + Going, 19 + Notgoing, 20 + string, 21 + } 22 + 23 + /** Interested in the event */ 24 + @token 25 + model Interested {} 26 + 27 + /** Going to the event */ 28 + @token 29 + model Going {} 30 + 31 + /** Not going to the event */ 32 + @token 33 + model Notgoing {} 34 + } 35 + 36 + // --- Externals --- 37 + 38 + @external 39 + namespace `com`.atproto.repo.strongRef { 40 + model Main {} 41 + }
+119 -1
packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json
··· 2 2 "lexicon": 1, 3 3 "id": "community.lexicon.calendar.event", 4 4 "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A calendar event.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "name", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "name": { 17 + "type": "string", 18 + "description": "The name of the event." 19 + }, 20 + "description": { 21 + "type": "string", 22 + "description": "The description of the event." 23 + }, 24 + "createdAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Client-declared timestamp when the event was created." 28 + }, 29 + "startsAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "Client-declared timestamp when the event starts." 33 + }, 34 + "endsAt": { 35 + "type": "string", 36 + "format": "datetime", 37 + "description": "Client-declared timestamp when the event ends." 38 + }, 39 + "mode": { 40 + "type": "ref", 41 + "ref": "#mode", 42 + "description": "The attendance mode of the event." 43 + }, 44 + "status": { 45 + "type": "ref", 46 + "ref": "#status", 47 + "description": "The status of the event." 48 + }, 49 + "locations": { 50 + "type": "array", 51 + "description": "The locations where the event takes place.", 52 + "items": { 53 + "type": "union", 54 + "refs": [ 55 + "#uri", 56 + "community.lexicon.location.address", 57 + "community.lexicon.location.fsq", 58 + "community.lexicon.location.geo", 59 + "community.lexicon.location.hthree" 60 + ] 61 + } 62 + }, 63 + "uris": { 64 + "type": "array", 65 + "description": "URIs associated with the event.", 66 + "items": { 67 + "type": "ref", 68 + "ref": "#uri" 69 + } 70 + } 71 + } 72 + } 73 + }, 5 74 "mode": { 6 75 "type": "string", 7 76 "description": "The mode of the event.", ··· 23 92 "hybrid": { 24 93 "type": "token", 25 94 "description": "A hybrid event that takes place both online and offline." 95 + }, 96 + "status": { 97 + "type": "string", 98 + "description": "The status of the event.", 99 + "default": "community.lexicon.calendar.event#scheduled", 100 + "knownValues": [ 101 + "community.lexicon.calendar.event#cancelled", 102 + "community.lexicon.calendar.event#planned", 103 + "community.lexicon.calendar.event#postponed", 104 + "community.lexicon.calendar.event#rescheduled", 105 + "community.lexicon.calendar.event#scheduled" 106 + ] 107 + }, 108 + "planned": { 109 + "type": "token", 110 + "description": "The event has been created, but not finalized." 111 + }, 112 + "scheduled": { 113 + "type": "token", 114 + "description": "The event has been created and scheduled." 115 + }, 116 + "rescheduled": { 117 + "type": "token", 118 + "description": "The event has been rescheduled." 119 + }, 120 + "cancelled": { 121 + "type": "token", 122 + "description": "The event has been cancelled." 123 + }, 124 + "postponed": { 125 + "type": "token", 126 + "description": "The event has been postponed and a new start date has not been set." 127 + }, 128 + "uri": { 129 + "type": "object", 130 + "description": "A URI associated with the event.", 131 + "required": [ 132 + "uri" 133 + ], 134 + "properties": { 135 + "uri": { 136 + "type": "string", 137 + "format": "uri" 138 + }, 139 + "name": { 140 + "type": "string", 141 + "description": "The display name of the URI." 142 + } 143 + } 26 144 } 27 145 } 28 - } 146 + }
+1 -1
packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json
··· 42 42 "description": "Not going to the event" 43 43 } 44 44 } 45 - } 45 + }

History

2 rounds 0 comments
sign up or login to add to the discussion
3 commits
expand
add @default, don't inline scalars unless @inline
add @default decorator; uninline scalars by default
fix types
1/1 success
expand
expand 0 comments
pull request successfully merged
danabra.mov submitted #0
2 commits
expand
add @default, don't inline scalars unless @inline
add @default decorator; uninline scalars by default
1/1 success
expand
expand 0 comments