An experimental TypeSpec syntax for Lexicon

good

+122 -55
+68 -4
SYNTAX.md
··· 457 457 458 458 ## Strings with Known Values 459 459 460 - ### Inline (Property Level) 460 + ### Open Enums (Inline, Property Level) 461 461 462 462 <table> 463 463 <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> ··· 507 507 508 508 **Atproto idiom:** Always include `| string` for extensibility. This allows new values without breaking old clients. 509 509 510 + ### Closed Enums (Inline, Rare) 511 + 512 + For **truly fixed** string sets that will never change: 513 + 514 + <table> 515 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 516 + <tr><td> 517 + 518 + ```typespec 519 + model Config { 520 + @closed 521 + @inline 522 + union Status { 523 + "draft", 524 + "published", 525 + "archived", 526 + } 527 + 528 + @doc("Current status - fixed set") 529 + @required 530 + status: Status; 531 + } 532 + ``` 533 + 534 + </td><td> 535 + 536 + ```json 537 + { 538 + "type": "object", 539 + "required": ["status"], 540 + "properties": { 541 + "status": { 542 + "type": "string", 543 + "enum": ["draft", "published", "archived"], 544 + "description": "Current status - fixed set" 545 + } 546 + } 547 + } 548 + ``` 549 + 550 + </td></tr> 551 + </table> 552 + 553 + **Note:** Closed enums use `enum` (not `knownValues`). Only use for values that will **never** expand. 554 + 510 555 ### Named (Def Level) 511 556 512 557 <table> ··· 689 734 690 735 ```typespec 691 736 namespace com.atproto.repo.applyWrites { 737 + @closed 738 + @inline 739 + union WriteAction { 740 + Create, 741 + Update, 742 + Delete, 743 + } 744 + 745 + @closed 746 + @inline 747 + union WriteResult { 748 + CreateResult, 749 + UpdateResult, 750 + DeleteResult, 751 + } 752 + 692 753 @procedure 693 754 op main(input: { 694 755 @required repo: atIdentifier; 695 756 696 757 // Closed union - write operations are fixed 697 758 @required 698 - writes: Closed<Create | Update | Delete>[]; 759 + writes: WriteAction[]; 699 760 }): { 700 - results?: Closed<CreateResult | UpdateResult | DeleteResult>[]; 761 + results?: WriteResult[]; 701 762 }; 702 763 703 764 model Create { ··· 812 873 - Internal server operations (like applyWrites) 813 874 - Batch operations with fixed types 814 875 - NOT for user-facing content or records 876 + 877 + **Note:** Use `@closed @inline` together to create a closed union that stays inline instead of becoming a separate def. 815 878 816 879 ### Empty Union 817 880 ··· 1609 1672 | `{ "type": "ref", "ref": "ns.id#foo" }` | `ns.id.Foo` | Cross namespace def | 1610 1673 | `{ "type": "ref", "ref": "ns.id" }` | `ns.id.Main` | Cross namespace main | 1611 1674 | `{ "type": "union", "refs": [...] }` | `(A \| B \| unknown)` | **Default - always use** | 1612 - | `{ "type": "union", "refs": [...], "closed": true }` | `Closed<A \| B>` | **Rare - internal ops only** | 1675 + | `{ "type": "union", "refs": [...], "closed": true }` | `@closed @inline union { A, B }` | **Rare - internal ops only** | 1613 1676 | `{ "type": "union", "refs": [] }` | `(never \| unknown)` | Empty union | 1614 1677 | `{ "type": "string", "knownValues": [...] }` | `"a" \| "b" \| string` | **Always include string** | 1678 + | `{ "type": "string", "enum": [...] }` | `@closed @inline union { "a", "b" }` | **Rare - truly fixed strings** | 1615 1679 | `{ "type": "token" }` | `@token model M {}` | String constant | 1616 1680 | `{ "type": "record", "key": "tid" }` | `@record("tid") model Main` | | 1617 1681 | `{ "type": "query" }` | `@query op main(...)` | Usually with limit/cursor |
-14
packages/emitter/lib/main.tsp
··· 34 34 _blob: never; 35 35 } 36 36 37 - /** 38 - * Creates a closed union from an inline union expression. 39 - * 40 - * This is an explicit opt-in for closed unions when written inline. 41 - * For named unions, use the @closed decorator instead. 42 - * 43 - * @template T A union type without `unknown` variant 44 - * 45 - * @example Closed inline union 46 - * ```typespec 47 - * embed?: Closed<Images | External | Record>; 48 - * ``` 49 - */ 50 - model Closed<T> {} 51 37 52 38 /** 53 39 * AT Protocol format scalars.
+27 -33
packages/emitter/src/emitter.ts
··· 326 326 const name = (union as any).name; 327 327 if (!name) return; 328 328 329 + // Skip @inline unions - they should be inlined, not defined separately 330 + if (isInline(this.program, union)) return; 331 + 329 332 const unionDef: any = this.typeToLexiconDefinition(union, undefined, true); 330 333 if (!unionDef) return; 331 334 332 335 if ( 333 336 unionDef.type === "union" || 334 - (unionDef.type === "string" && unionDef.knownValues) 337 + (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) 335 338 ) { 336 339 const defName = name.charAt(0).toLowerCase() + name.slice(1); 337 340 const description = getDoc(this.program, union); ··· 356 359 ); 357 360 } 358 361 359 - private isClosedUnionTemplate(model: Model): boolean { 360 - return !!( 361 - model.name === "Closed" || 362 - (model.node && (model.node as any).symbol?.name === "Closed") || 363 - (isTemplateInstance(model) && 364 - model.node && 365 - (model.node as any).symbol?.name === "Closed") 366 - ); 367 - } 368 362 369 363 private createBlobDef(model: Model): LexiconBlob { 370 364 const blobDef: LexiconBlob = { type: "blob" }; ··· 407 401 // Parse union variants 408 402 const variants = this.parseUnionVariants(unionType); 409 403 410 - // Case 1: String enum with known values (string literals + string type) 411 - if (variants.isStringEnum) { 404 + // Case 1: String enum (string literals with or without string type) 405 + // isStringEnum: has literals + string type + no refs 406 + // Closed enum: has literals + no string type + no refs + @closed 407 + if (variants.isStringEnum || 408 + (variants.stringLiterals.length > 0 && 409 + !variants.hasStringType && 410 + variants.unionRefs.length === 0 && 411 + isClosed(this.program, unionType))) { 412 412 return this.createStringEnumDef(unionType, variants.stringLiterals, prop); 413 413 } 414 414 ··· 428 428 return null; 429 429 } 430 430 431 - // Case 4: Invalid string literal union (has literals but no string type) 431 + // Case 4: Invalid string literal union (has literals but no string type and not @closed) 432 432 if (variants.stringLiterals.length > 0 && !variants.hasStringType) { 433 433 throw new Error( 434 - 'String literal unions must include "| string" to allow unknown values', 434 + 'String literal unions must include "| string" to allow unknown values. ' + 435 + 'Use @closed decorator if this is intentionally a fixed enum.', 435 436 ); 436 437 } 437 438 ··· 487 488 stringLiterals: string[], 488 489 prop?: ModelProperty, 489 490 ): LexiconDefinition { 490 - const primitive: any = { type: "string", knownValues: stringLiterals }; 491 + // Use "enum" for @closed unions, "knownValues" for open unions 492 + const isClosedUnion = isClosed(this.program, unionType); 493 + const primitive: any = { 494 + type: "string", 495 + [isClosedUnion ? "enum" : "knownValues"]: stringLiterals, 496 + }; 491 497 492 498 // Apply constraints 493 499 const maxLength = getMaxLength(this.program, unionType); ··· 885 891 return this.addDescription(this.createBlobDef(model), propDesc); 886 892 } 887 893 888 - // 2. Check for Closed<Union> template 889 - if (this.isClosedUnionTemplate(model)) { 890 - const unionArg = model.templateMapper?.args?.[0]; 891 - if (unionArg && isType(unionArg) && unionArg.kind === "Union") { 892 - const unionDef = this.typeToLexiconDefinition(unionArg, prop); 893 - if (unionDef && unionDef.type === "union") { 894 - (unionDef as LexiconUnion).closed = true; 895 - return unionDef; 896 - } 897 - } 898 - throw new Error( 899 - "Closed<> template argument must be a union of model references", 900 - ); 901 - } 902 - 903 - // 3. Check for model reference (named models from other namespaces) 894 + // 2. Check for model reference (named models from other namespaces) 904 895 const modelRef = this.getModelReference(model); 905 896 if (modelRef) { 906 897 return this.addDescription({ type: "ref", ref: modelRef }, propDesc); 907 898 } 908 899 909 - // 4. Check for array type 900 + // 3. Check for array type 910 901 if (isArrayModelType(this.program, model)) { 911 902 const arrayDef = this.modelToLexiconArray(model, prop); 912 903 if (!arrayDef) { ··· 917 908 return this.addDescription(arrayDef, propDesc); 918 909 } 919 910 920 - // 5. Inline object 911 + // 4. Inline object 921 912 return this.addDescription(this.modelToLexiconObject(model), propDesc); 922 913 } 923 914 ··· 1124 1115 const unionName = (union as any).name; 1125 1116 const namespace = (union as any).namespace; 1126 1117 if (!unionName || !namespace || namespace.name === "TypeSpec") return null; 1118 + 1119 + // If union is marked as @inline, don't create a reference - inline it instead 1120 + if (isInline(this.program, union)) return null; 1127 1121 1128 1122 const namespaceName = getNamespaceFullName(namespace); 1129 1123 if (!namespaceName) return null;
+18 -2
packages/emitter/test/integration/atproto/input/com/atproto/repo/applyWrites.tsp
··· 4 4 @doc("Indicates that the 'swapCommit' parameter did not match current commit.") 5 5 model InvalidSwap {} 6 6 7 + @closed 8 + @inline 9 + union WriteAction { 10 + Create, 11 + Update, 12 + Delete, 13 + } 14 + 15 + @closed 16 + @inline 17 + union WriteResult { 18 + CreateResult, 19 + UpdateResult, 20 + DeleteResult, 21 + } 22 + 7 23 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.") 8 24 @procedure 9 25 @errors(InvalidSwap) ··· 16 32 validate?: boolean; 17 33 18 34 @required 19 - writes: Closed<Create | Update | Delete>[]; 35 + writes: WriteAction[]; 20 36 21 37 @doc("If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.") 22 38 swapCommit?: cid; 23 39 }): { 24 40 commit?: com.atproto.repo.defs.CommitMeta; 25 - results?: Closed<CreateResult | UpdateResult | DeleteResult>[]; 41 + results?: WriteResult[]; 26 42 }; 27 43 28 44 @doc("Operation which creates a new record.")
+1 -1
packages/emitter/test/integration/atproto/input/tools/ozone/moderation/defs.tsp
··· 148 148 @required subject: string; 149 149 status?: SubjectStatusView; 150 150 repo?: RepoViewDetail; 151 - profile?: unknown; 151 + profile?: (never | unknown); 152 152 record?: RecordViewDetail; 153 153 } 154 154
+8 -1
packages/emitter/test/integration/atproto/input/tools/ozone/verification/listVerifications.tsp
··· 1 1 import "@tlex/emitter"; 2 2 3 3 namespace tools.ozone.verification.listVerifications { 4 + @closed 5 + @inline 6 + union SortDirection { 7 + "asc", 8 + "desc", 9 + } 10 + 4 11 @doc("List verifications") 5 12 @query 6 13 op main( ··· 27 34 subjects?: did[], 28 35 29 36 @doc("Sort direction for creation date") 30 - sortDirection?: "asc" | "desc" | string = "desc", 37 + sortDirection?: SortDirection = "desc", 31 38 32 39 @doc("Filter to verifications that are revoked or not. By default, includes both.") 33 40 isRevoked?: boolean