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
+1128 -36
Diff #1
+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
+297 -33
packages/emitter/src/emitter.ts
··· 45 45 46 46 47 47 48 + LexCidLink, 49 + LexRefVariant, 50 + LexToken, 51 + LexBoolean, 52 + LexInteger, 53 + LexString, 54 + } from "./types.js"; 48 55 56 + import { 49 57 50 58 51 59 ··· 60 68 61 69 62 70 63 - 64 - 65 - 66 - 67 - 68 71 getMaxBytes, 69 72 getMinBytes, 70 73 isExternal, 74 + getDefault, 71 75 } from "./decorators.js"; 72 76 73 77 export interface EmitterOptions { ··· 98 102 private options: EmitterOptions, 99 103 ) {} 100 104 105 + /** 106 + * Process the raw default value from the decorator, unwrapping TypeSpec value objects 107 + * and returning either a primitive (string, number, boolean) or a Type (for model references) 108 + */ 109 + private processDefaultValue(rawValue: any): string | number | boolean | Type | undefined { 110 + if (rawValue === undefined) return undefined; 111 + 112 + // TypeSpec may wrap values - check if this is a value object first 113 + if (rawValue && typeof rawValue === 'object' && rawValue.valueKind) { 114 + if (rawValue.valueKind === "StringValue") { 115 + return rawValue.value; 116 + } else if (rawValue.valueKind === "NumericValue" || rawValue.valueKind === "NumberValue") { 117 + return rawValue.value; 118 + } else if (rawValue.valueKind === "BooleanValue") { 119 + return rawValue.value; 120 + } 121 + return undefined; // Unsupported valueKind 122 + } 123 + 124 + // Check if it's a Type object (Model, String, Number, Boolean literals) 125 + if (rawValue && typeof rawValue === 'object' && rawValue.kind) { 126 + if (rawValue.kind === "String") { 127 + return (rawValue as StringLiteral).value; 128 + } else if (rawValue.kind === "Number") { 129 + return (rawValue as NumericLiteral).value; 130 + } else if (rawValue.kind === "Boolean") { 131 + return (rawValue as BooleanLiteral).value; 132 + } else if (rawValue.kind === "Model") { 133 + // Return the model itself for token references 134 + return rawValue as Model; 135 + } 136 + return undefined; // Unsupported kind 137 + } 138 + 139 + // Direct primitive value 140 + if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') { 141 + return rawValue; 142 + } 143 + 144 + return undefined; 145 + } 146 + 101 147 async emit() { 102 148 const globalNs = this.program.getGlobalNamespaceType(); 103 149 ··· 356 402 } 357 403 358 404 private addScalarToDefs(lexicon: LexiconDoc, scalar: Scalar) { 405 + // Only skip if the scalar itself is in TypeSpec namespace (built-in scalars) 359 406 if (scalar.namespace?.name === "TypeSpec") return; 360 - if (scalar.baseScalar?.namespace?.name === "TypeSpec") return; 361 407 362 408 // Skip @inline scalars - they should be inlined, not defined separately 363 409 if (isInline(this.program, scalar)) { ··· 368 414 const scalarDef = this.scalarToLexiconPrimitive(scalar, undefined); 369 415 if (scalarDef) { 370 416 const description = getDoc(this.program, scalar); 371 - lexicon.defs[defName] = { ...scalarDef, description } as LexUserType; 417 + 418 + // Apply @default decorator if present 419 + const rawDefault = getDefault(this.program, scalar); 420 + const defaultValue = this.processDefaultValue(rawDefault); 421 + let defWithDefault: LexObjectProperty = { ...scalarDef }; 422 + 423 + if (defaultValue !== undefined) { 424 + // Check if it's a Type (model reference for tokens) 425 + if (typeof defaultValue === 'object' && 'kind' in defaultValue) { 426 + // For model references, we need to resolve to NSID 427 + // This shouldn't happen for scalars, only unions support token refs 428 + this.program.reportDiagnostic({ 429 + code: "invalid-default-on-scalar", 430 + severity: "error", 431 + message: "@default on scalars must be a literal value (string, number, or boolean), not a model reference", 432 + target: scalar, 433 + }); 434 + } else { 435 + // Validate that the default value matches the type 436 + this.assertValidValueForType(scalarDef.type, defaultValue, scalar); 437 + // Type-safe narrowing based on both the type discriminator and value type 438 + if (scalarDef.type === "boolean" && typeof defaultValue === "boolean") { 439 + (defWithDefault as LexBoolean).default = defaultValue; 440 + } else if (scalarDef.type === "integer" && typeof defaultValue === "number") { 441 + (defWithDefault as LexInteger).default = defaultValue; 442 + } else if (scalarDef.type === "string" && typeof defaultValue === "string") { 443 + (defWithDefault as LexString).default = defaultValue; 444 + } 445 + } 446 + } 447 + 448 + // Apply integer constraints for standalone scalar defs 449 + if (scalarDef.type === "integer") { 450 + const minValue = getMinValue(this.program, scalar); 451 + if (minValue !== undefined) { 452 + (defWithDefault as LexInteger).minimum = minValue; 453 + } 454 + const maxValue = getMaxValue(this.program, scalar); 455 + if (maxValue !== undefined) { 456 + (defWithDefault as LexInteger).maximum = maxValue; 457 + } 458 + } 459 + 460 + lexicon.defs[defName] = { ...defWithDefault, description } as LexUserType; 372 461 } 373 462 } 374 463 ··· 391 480 if (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) { 392 481 const defName = name.charAt(0).toLowerCase() + name.slice(1); 393 482 const description = getDoc(this.program, union); 394 - lexicon.defs[defName] = { ...unionDef, description }; 483 + 484 + // Apply @default decorator if present 485 + const rawDefault = getDefault(this.program, union); 486 + const defaultValue = this.processDefaultValue(rawDefault); 487 + let defWithDefault: LexString = { ...unionDef as LexString }; 488 + 489 + if (defaultValue !== undefined) { 490 + // Check if it's a Type (model reference for tokens) 491 + if (typeof defaultValue === 'object' && 'kind' in defaultValue) { 492 + // Resolve the model reference to its NSID 493 + const tokenModel = defaultValue as Model; 494 + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true 495 + if (tokenRef) { 496 + defWithDefault = { ...defWithDefault, default: tokenRef }; 497 + } else { 498 + this.program.reportDiagnostic({ 499 + code: "invalid-default-token", 500 + severity: "error", 501 + message: "@default value must be a valid token model reference", 502 + target: union, 503 + }); 504 + } 505 + } else { 506 + // Literal value - validate it matches the union type 507 + if (typeof defaultValue !== "string") { 508 + this.program.reportDiagnostic({ 509 + code: "invalid-default-value-type", 510 + severity: "error", 511 + message: `Default value type mismatch: expected string, got ${typeof defaultValue}`, 512 + target: union, 513 + }); 514 + } else { 515 + defWithDefault = { ...defWithDefault, default: defaultValue }; 516 + } 517 + } 518 + } 519 + 520 + lexicon.defs[defName] = { ...defWithDefault, description }; 395 521 } else if (unionDef.type === "union") { 396 522 this.program.reportDiagnostic({ 397 523 code: "union-refs-not-allowed-as-def", ··· 401 527 `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, 402 528 target: union, 403 529 }); 404 - } 405 - } 530 + } else if (unionDef.type === "integer" && (unionDef as LexInteger).enum) { 531 + // Integer enums can also be defs 532 + const defName = name.charAt(0).toLowerCase() + name.slice(1); 533 + const description = getDoc(this.program, union); 406 534 535 + // Apply @default decorator if present 536 + const rawDefault = getDefault(this.program, union); 537 + const defaultValue = this.processDefaultValue(rawDefault); 538 + let defWithDefault: LexInteger = { ...unionDef as LexInteger }; 407 539 540 + if (defaultValue !== undefined) { 541 + if (typeof defaultValue === "number") { 542 + defWithDefault = { ...defWithDefault, default: defaultValue }; 543 + } else { 544 + this.program.reportDiagnostic({ 545 + code: "invalid-default-value-type", 546 + severity: "error", 547 + message: `Default value type mismatch: expected integer, got ${typeof defaultValue}`, 548 + target: union, 549 + }); 550 + } 551 + } 408 552 553 + lexicon.defs[defName] = { ...defWithDefault, description }; 554 + } 555 + } 409 556 410 557 411 558 ··· 501 648 502 649 503 650 651 + isClosed(this.program, unionType) 652 + ) { 653 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 504 654 655 + // Check for default value: property default takes precedence, then union's @default 656 + let defaultValue: string | number | boolean | undefined; 657 + if (prop?.defaultValue !== undefined) { 658 + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as string | number | boolean; 659 + } else { 660 + // If no property default, check union's @default decorator 661 + const rawUnionDefault = getDefault(this.program, unionType); 662 + const unionDefault = this.processDefaultValue(rawUnionDefault); 663 + if (unionDefault !== undefined && typeof unionDefault === 'number') { 664 + defaultValue = unionDefault; 665 + } 666 + } 505 667 668 + return { 669 + type: "integer", 670 + enum: variants.numericLiterals, 506 671 507 672 508 673 ··· 519 684 520 685 521 686 687 + ) { 688 + const isClosedUnion = isClosed(this.program, unionType); 689 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 522 690 691 + // Check for default value: property default takes precedence, then union's @default 692 + let defaultValue: string | number | boolean | undefined; 693 + if (prop?.defaultValue !== undefined) { 694 + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as string | number | boolean; 695 + } else { 696 + // If no property default, check union's @default decorator 697 + const rawUnionDefault = getDefault(this.program, unionType); 698 + const unionDefault = this.processDefaultValue(rawUnionDefault); 523 699 700 + if (unionDefault !== undefined) { 701 + // Check if it's a Type (model reference for tokens) 702 + if (typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { 703 + // Resolve the model reference to its NSID 704 + const tokenModel = unionDefault as Model; 705 + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true 706 + if (tokenRef) { 707 + defaultValue = tokenRef; 708 + } 709 + } else if (typeof unionDefault === 'string') { 710 + defaultValue = unionDefault; 711 + } 712 + } 713 + } 524 714 715 + const maxLength = getMaxLength(this.program, unionType); 716 + const minLength = getMinLength(this.program, unionType); 717 + const maxGraphemes = getMaxGraphemes(this.program, unionType); 525 718 526 719 527 720 ··· 1145 1338 1146 1339 1147 1340 1148 - 1149 - 1150 - 1151 - 1152 - 1153 - 1154 - 1155 - 1156 - 1157 - 1158 1341 prop?: ModelProperty, 1159 1342 propDesc?: string, 1160 1343 ): LexObjectProperty | null { 1344 + // Check if this scalar should be referenced instead of inlined 1345 + const scalarRef = this.getScalarReference(scalar); 1346 + if (scalarRef) { 1347 + // Check if property has a default value that would conflict with the scalar's @default 1348 + if (prop?.defaultValue !== undefined) { 1349 + const scalarDefaultRaw = getDefault(this.program, scalar); 1350 + const scalarDefault = this.processDefaultValue(scalarDefaultRaw); 1351 + const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); 1352 + 1353 + // If the scalar has a different default, or if the property has a default but the scalar doesn't, error 1354 + if (scalarDefault !== propDefault) { 1355 + this.program.reportDiagnostic({ 1356 + code: "conflicting-defaults", 1357 + severity: "error", 1358 + message: scalarDefault !== undefined 1359 + ? `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.` 1360 + : `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.`, 1361 + target: prop, 1362 + }); 1363 + } 1364 + } 1365 + 1366 + return { type: "ref" as const, ref: scalarRef, description: propDesc }; 1367 + } 1368 + 1369 + // Inline the scalar 1161 1370 const primitive = this.scalarToLexiconPrimitive(scalar, prop); 1162 1371 if (!primitive) return null; 1163 1372 ··· 1246 1455 if (!isDefining) { 1247 1456 const unionRef = this.getUnionReference(unionType); 1248 1457 if (unionRef) { 1458 + // Check if property has a default value that would conflict with the union's @default 1459 + if (prop?.defaultValue !== undefined) { 1460 + const unionDefaultRaw = getDefault(this.program, unionType); 1461 + const unionDefault = this.processDefaultValue(unionDefaultRaw); 1462 + const propDefault = serializeValueAsJson(this.program, prop.defaultValue, prop); 1463 + 1464 + // For union defaults that are model references, we need to resolve them for comparison 1465 + let resolvedUnionDefault: string | number | boolean | undefined; 1466 + if (unionDefault && typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { 1467 + const ref = this.getModelReference(unionDefault as Model, true); 1468 + resolvedUnionDefault = ref || undefined; 1469 + } else { 1470 + resolvedUnionDefault = unionDefault as string | number | boolean; 1471 + } 1472 + 1473 + // If the union has a different default, or if the property has a default but the union doesn't, error 1474 + if (resolvedUnionDefault !== propDefault) { 1475 + this.program.reportDiagnostic({ 1476 + code: "conflicting-defaults", 1477 + severity: "error", 1478 + message: unionDefault !== undefined 1479 + ? `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.` 1480 + : `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.`, 1481 + target: prop, 1482 + }); 1483 + } 1484 + } 1485 + 1249 1486 return { type: "ref" as const, ref: unionRef, description: propDesc }; 1250 1487 } 1251 1488 } ··· 1271 1508 // Check if this scalar (or its base) is bytes type 1272 1509 if (this.isScalarOfType(scalar, "bytes")) { 1273 1510 const byteDef: LexBytes = { type: "bytes" }; 1274 - const target = prop || scalar; 1275 1511 1276 - const minLength = getMinBytes(this.program, target); 1512 + // Check scalar first for its own constraints, then property overrides 1513 + const minLength = getMinBytes(this.program, scalar) ?? (prop ? getMinBytes(this.program, prop) : undefined); 1277 1514 if (minLength !== undefined) { 1278 1515 byteDef.minLength = minLength; 1279 1516 } 1280 1517 1281 - const maxLength = getMaxBytes(this.program, target); 1518 + const maxLength = getMaxBytes(this.program, scalar) ?? (prop ? getMaxBytes(this.program, prop) : undefined); 1282 1519 if (maxLength !== undefined) { 1283 1520 byteDef.maxLength = maxLength; 1284 1521 } ··· 1310 1547 1311 1548 // Apply string constraints 1312 1549 if (primitive.type === "string") { 1313 - const target = prop || scalar; 1314 - const maxLength = getMaxLength(this.program, target); 1550 + // Check scalar first for its own constraints, then property overrides 1551 + const maxLength = getMaxLength(this.program, scalar) ?? (prop ? getMaxLength(this.program, prop) : undefined); 1315 1552 if (maxLength !== undefined) { 1316 1553 primitive.maxLength = maxLength; 1317 1554 } 1318 - const minLength = getMinLength(this.program, target); 1555 + const minLength = getMinLength(this.program, scalar) ?? (prop ? getMinLength(this.program, prop) : undefined); 1319 1556 if (minLength !== undefined) { 1320 1557 primitive.minLength = minLength; 1321 1558 } 1322 - const maxGraphemes = getMaxGraphemes(this.program, target); 1559 + const maxGraphemes = getMaxGraphemes(this.program, scalar) ?? (prop ? getMaxGraphemes(this.program, prop) : undefined); 1323 1560 if (maxGraphemes !== undefined) { 1324 1561 primitive.maxGraphemes = maxGraphemes; 1325 1562 } 1326 - const minGraphemes = getMinGraphemes(this.program, target); 1563 + const minGraphemes = getMinGraphemes(this.program, scalar) ?? (prop ? getMinGraphemes(this.program, prop) : undefined); 1327 1564 if (minGraphemes !== undefined) { 1328 1565 primitive.minGraphemes = minGraphemes; 1329 1566 } 1330 1567 } 1331 1568 1332 1569 // Apply numeric constraints 1333 - if (prop && primitive.type === "integer") { 1334 - const minValue = getMinValue(this.program, prop); 1570 + if (primitive.type === "integer") { 1571 + // Check scalar first for its own constraints, then property overrides 1572 + const minValue = getMinValue(this.program, scalar) ?? (prop ? getMinValue(this.program, prop) : undefined); 1335 1573 if (minValue !== undefined) { 1336 1574 primitive.minimum = minValue; 1337 1575 } 1338 - const maxValue = getMaxValue(this.program, prop); 1576 + const maxValue = getMaxValue(this.program, scalar) ?? (prop ? getMaxValue(this.program, prop) : undefined); 1339 1577 if (maxValue !== undefined) { 1340 1578 primitive.maximum = maxValue; 1341 1579 } ··· 1431 1669 private assertValidValueForType( 1432 1670 primitiveType: string, 1433 1671 value: unknown, 1434 - prop: ModelProperty, 1672 + target: ModelProperty | Scalar | Union, 1435 1673 ): void { 1436 1674 const valid = 1437 1675 (primitiveType === "boolean" && typeof value === "boolean") || ··· 1442 1680 code: "invalid-default-value-type", 1443 1681 severity: "error", 1444 1682 message: `Default value type mismatch: expected ${primitiveType}, got ${typeof value}`, 1445 - target: prop, 1683 + target: target, 1446 1684 }); 1447 1685 } 1448 1686 } ··· 1507 1745 1508 1746 1509 1747 return this.getReference(union, union.name, union.namespace); 1748 + } 1749 + 1750 + private getScalarReference(scalar: Scalar): string | null { 1751 + // Built-in TypeSpec scalars (string, integer, boolean themselves) should not be referenced 1752 + if (scalar.namespace?.name === "TypeSpec") return null; 1753 + 1754 + // @inline scalars should be inlined, not referenced 1755 + if (isInline(this.program, scalar)) return null; 1756 + 1757 + // Scalars without names or namespace can't be referenced 1758 + if (!scalar.name || !scalar.namespace) return null; 1759 + 1760 + const defName = scalar.name.charAt(0).toLowerCase() + scalar.name.slice(1); 1761 + const namespaceName = getNamespaceFullName(scalar.namespace); 1762 + if (!namespaceName) return null; 1763 + 1764 + // Local reference (same namespace) - use short ref 1765 + if ( 1766 + this.currentLexiconId === namespaceName || 1767 + this.currentLexiconId === `${namespaceName}.defs` 1768 + ) { 1769 + return `#${defName}`; 1770 + } 1771 + 1772 + // Cross-namespace reference 1773 + return `${namespaceName}#${defName}`; 1510 1774 } 1511 1775 1512 1776 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
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