An experimental TypeSpec syntax for Lexicon

wip

+226 -108
+15
packages/emitter/lib/main.tsp
··· 35 35 } 36 36 37 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 + 52 + /** 38 53 * AT Protocol format scalars. 39 54 * These map to Lexicon string types with specific format constraints. 40 55 */
+51 -27
packages/emitter/src/emitter.ts
··· 405 405 if (!unionDef) return; 406 406 407 407 // Check if union contains only models (object types) 408 - // If so, wrap in array type (for Preferences pattern) 409 - // Otherwise, emit as-is (for string unions with knownValues) 410 408 let hasModelTypes = false; 411 409 for (const variant of union.variants.values()) { 412 410 if (variant.type.kind === "Model") { ··· 417 415 418 416 const description = getDoc(this.program, union); 419 417 418 + // Closed model unions are NOT added to defs - they are only used inline 419 + // This allows @closed unions like Write { Create | Update | Delete } to work 420 + if (hasModelTypes && isClosed(this.program, union)) { 421 + return; 422 + } 423 + 420 424 if (hasModelTypes && unionDef.type === "union") { 421 - // Wrap union of models in array type 425 + // Wrap open union of models in array type (for Preferences pattern) 422 426 const arrayDef: any = { 423 427 type: "array", 424 428 items: unionDef, ··· 796 800 if (prop.optional !== true) { 797 801 // Field is required - check if it has the @required decorator 798 802 if (!isRequired(this.program, prop)) { 799 - throw new Error( 800 - `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` + 803 + this.program.reportDiagnostic({ 804 + code: "closed-open-union-inline", 805 + message: 806 + `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` + 801 807 `In atproto, required fields are discouraged and must be intentional. ` + 802 808 `Either add @required to the field or make it optional with "?".`, 803 - ); 809 + target: model, 810 + severity: "error", 811 + }); 804 812 } 805 813 required.push(name); 806 814 } ··· 1101 1109 // Check if this is a named union that should be referenced 1102 1110 // (but not if we're defining the union itself) 1103 1111 if (!isDefining) { 1104 - const unionRef = this.getUnionReference(unionType); 1105 - if (unionRef) { 1106 - const refDef: LexiconRef = { 1107 - type: "ref", 1108 - ref: unionRef, 1109 - }; 1110 - if (prop) { 1111 - const propDesc = getDoc(this.program, prop); 1112 - if (propDesc) { 1113 - refDef.description = propDesc; 1112 + // Check if this is a closed model union 1113 + let hasModels = false; 1114 + for (const variant of unionType.variants.values()) { 1115 + if (variant.type.kind === "Model") { 1116 + hasModels = true; 1117 + break; 1118 + } 1119 + } 1120 + 1121 + // Skip references for @closed model unions - they should be inlined 1122 + // All other named unions can be referenced 1123 + if (!(hasModels && isClosed(this.program, unionType))) { 1124 + const unionRef = this.getUnionReference(unionType); 1125 + if (unionRef) { 1126 + const refDef: LexiconRef = { 1127 + type: "ref", 1128 + ref: unionRef, 1129 + }; 1130 + if (prop) { 1131 + const propDesc = getDoc(this.program, prop); 1132 + if (propDesc) { 1133 + refDef.description = propDesc; 1134 + } 1114 1135 } 1136 + return refDef; 1115 1137 } 1116 - return refDef; 1117 1138 } 1118 1139 } 1119 1140 ··· 1203 1224 refs: unionRefs, 1204 1225 }; 1205 1226 1206 - // Check for @closed decorator on the property or union itself 1207 - if (prop && isClosed(this.program, prop)) { 1208 - unionDef.closed = true; 1209 - } else if (isClosed(this.program, unionType)) { 1210 - unionDef.closed = true; 1227 + // Check for @closed decorator on the union itself (not on properties) 1228 + if (isClosed(this.program, unionType)) { 1229 + // Validate that @closed is not used on open unions (with unknown/never) 1230 + if (hasUnknown) { 1231 + this.program.reportDiagnostic({ 1232 + code: "closed-open-union", 1233 + message: 1234 + "@closed decorator cannot be used on open unions (unions containing 'unknown' or 'never'). Remove the @closed decorator or make the union closed by removing 'unknown'/'never'.", 1235 + target: unionType, 1236 + } as any); 1237 + } else { 1238 + unionDef.closed = true; 1239 + } 1211 1240 } 1212 1241 1213 1242 if (prop) { ··· 1517 1546 const itemDef = this.typeToLexiconDefinition(itemType); 1518 1547 1519 1548 if (itemDef) { 1520 - // Check if the property has @closed decorator and the item is a union 1521 - if (prop && isClosed(this.program, prop) && itemDef.type === 'union') { 1522 - (itemDef as any).closed = true; 1523 - } 1524 - 1525 1549 const arrayDef: LexiconArray = { 1526 1550 type: "array", 1527 1551 items: itemDef,
+2 -2
packages/emitter/src/types.ts
··· 6 6 defs: Record<string, LexiconDefinition>; 7 7 } 8 8 9 - export type LexiconDefinition = 9 + export type LexiconDefinition = 10 10 | LexiconRecord 11 11 | LexiconQuery 12 12 | LexiconProcedure ··· 150 150 export interface LexiconXrpcError { 151 151 name: string; 152 152 description?: string; 153 - } 153 + }
+1
packages/emitter/test/scenarios/atproto/input/app/bsky/actor/defs.tsp
··· 347 347 | app.bsky.feed.threadgate.FollowerRule 348 348 | app.bsky.feed.threadgate.FollowingRule 349 349 | app.bsky.feed.threadgate.ListRule 350 + | unknown 350 351 )[]; 351 352 352 353 @doc("Matches postgate record. List of rules defining who can embed this users posts. If value is an empty array or is undefined, no particular rules apply and anyone can embed.")
+1
packages/emitter/test/scenarios/atproto/input/app/bsky/bookmark/defs.tsp
··· 20 20 | app.bsky.feed.defs.BlockedPost 21 21 | app.bsky.feed.defs.NotFoundPost 22 22 | app.bsky.feed.defs.PostView 23 + | unknown 23 24 ); 24 25 } 25 26 }
+2
packages/emitter/test/scenarios/atproto/input/app/bsky/embed/record.tsp
··· 17 17 | app.bsky.graph.defs.ListView 18 18 | app.bsky.labeler.defs.LabelerView 19 19 | app.bsky.graph.defs.StarterPackViewBasic 20 + | unknown 20 21 ); 21 22 } 22 23 ··· 41 42 | app.bsky.embed.external.View 42 43 | app.bsky.embed.record.View 43 44 | app.bsky.embed.recordWithMedia.View 45 + | unknown 44 46 )[]; 45 47 46 48 @required indexedAt: datetime;
+2
packages/emitter/test/scenarios/atproto/input/app/bsky/embed/recordWithMedia.tsp
··· 10 10 | app.bsky.embed.images.Main 11 11 | app.bsky.embed.video.Main 12 12 | app.bsky.embed.external.Main 13 + | unknown 13 14 ); 14 15 } 15 16 ··· 21 22 | app.bsky.embed.images.View 22 23 | app.bsky.embed.video.View 23 24 | app.bsky.embed.external.View 25 + | unknown 24 26 ); 25 27 } 26 28 }
+5 -4
packages/emitter/test/scenarios/atproto/input/app/bsky/feed/defs.tsp
··· 13 13 | app.bsky.embed.external.View 14 14 | app.bsky.embed.record.View 15 15 | app.bsky.embed.recordWithMedia.View 16 + | unknown 16 17 ); 17 18 18 19 bookmarkCount?: integer; ··· 48 49 @required post: PostView; 49 50 50 51 reply?: ReplyRef; 51 - reason?: (ReasonRepost | ReasonPin); 52 + reason?: (ReasonRepost | ReasonPin | unknown); 52 53 53 54 @doc("Context provided by feed generator that may be passed back alongside interactions.") 54 55 @maxLength(2000) ··· 60 61 } 61 62 62 63 model ReplyRef { 63 - @required root: (PostView | NotFoundPost | BlockedPost); 64 - @required parent: (PostView | NotFoundPost | BlockedPost); 64 + @required root: (PostView | NotFoundPost | BlockedPost | unknown); 65 + @required parent: (PostView | NotFoundPost | BlockedPost | unknown); 65 66 66 67 @doc("When parent is a reply to another post, this is the author of that post.") 67 68 grandparentAuthor?: app.bsky.actor.defs.ProfileViewBasic; ··· 144 145 model SkeletonFeedPost { 145 146 @required post: atUri; 146 147 147 - reason?: (SkeletonReasonRepost | SkeletonReasonPin); 148 + reason?: (SkeletonReasonRepost | SkeletonReasonPin | unknown); 148 149 149 150 @doc("Context that will be passed through to client and may be passed to feed generator back alongside interactions.") 150 151 @maxLength(2000)
+1
packages/emitter/test/scenarios/atproto/input/app/bsky/feed/post.tsp
··· 24 24 | app.bsky.embed.external.Main 25 25 | app.bsky.embed.record.Main 26 26 | app.bsky.embed.recordWithMedia.Main 27 + | unknown 27 28 ); 28 29 29 30 @doc("Indicates human language of post primary text content.")
+16 -4
packages/emitter/test/scenarios/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 + union Write { 9 + Create, 10 + Update, 11 + Delete, 12 + } 13 + 14 + @closed 15 + union Result { 16 + CreateResult, 17 + UpdateResult, 18 + DeleteResult, 19 + } 20 + 7 21 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.") 8 22 @procedure 9 23 @errors(InvalidSwap) ··· 16 30 validate?: boolean; 17 31 18 32 @required 19 - @closed 20 - writes: (Create | Update | Delete)[]; 33 + writes: Write[]; 21 34 22 35 @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.") 23 36 swapCommit?: cid; 24 37 }): { 25 38 commit?: com.atproto.repo.defs.CommitMeta; 26 39 27 - @closed 28 - results?: (CreateResult | UpdateResult | DeleteResult)[]; 40 + results?: Result[]; 29 41 }; 30 42 31 43 @doc("Operation which creates a new record.")
+41
packages/emitter/test/scenarios/union-comprehensive/input/test/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace test.defs; 4 + 5 + model A {} 6 + model B {} 7 + model C {} 8 + 9 + // Named open union: separate declaration + has unknown 10 + union OpenNamed { 11 + A, 12 + B, 13 + C, 14 + unknown 15 + } 16 + 17 + // Named closed union: separate declaration + no unknown + @closed required 18 + @closed 19 + union ClosedNamed { 20 + A, 21 + B, 22 + C 23 + } 24 + 25 + model Examples { 26 + openNamedField?: OpenNamed; 27 + 28 + closedNamedField?: ClosedNamed; 29 + 30 + openInlineField?: A | B | C | unknown; 31 + 32 + closedInlineField?: Closed<A | B | C>; 33 + 34 + openNamedFieldArray?: OpenNamed[]; 35 + 36 + closedNamedFieldArray?: ClosedNamed[]; 37 + 38 + openInlineFieldArray?: (A | B | C | unknown)[]; 39 + 40 + closedInlineFieldArray?: (Closed<A | B | C>)[]; 41 + }
+78
packages/emitter/test/scenarios/union-comprehensive/output/test/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "test.defs", 4 + "defs": { 5 + "a": { 6 + "type": "object", 7 + "properties": {} 8 + }, 9 + "b": { 10 + "type": "object", 11 + "properties": {} 12 + }, 13 + "c": { 14 + "type": "object", 15 + "properties": {} 16 + }, 17 + "openNamed": { 18 + "type": "union", 19 + "refs": ["#a", "#b", "#c"] 20 + }, 21 + "closedNamed": { 22 + "type": "union", 23 + "refs": ["#a", "#b", "#c"], 24 + "closed": true 25 + }, 26 + "examples": { 27 + "type": "object", 28 + "properties": { 29 + "openNamedField": { 30 + "type": "ref", 31 + "ref": "#openNamed" 32 + }, 33 + "closedNamedField": { 34 + "type": "ref", 35 + "ref": "#closedNamed" 36 + }, 37 + "openInlineField": { 38 + "type": "union", 39 + "refs": ["#a", "#b", "#c"] 40 + }, 41 + "closedInlineField": { 42 + "type": "union", 43 + "refs": ["#a", "#b", "#c"], 44 + "closed": true 45 + }, 46 + "openNamedFieldArray": { 47 + "type": "array", 48 + "items": { 49 + "type": "ref", 50 + "ref": "#openNamed" 51 + } 52 + }, 53 + "closedNamedFieldArray": { 54 + "type": "array", 55 + "items": { 56 + "type": "ref", 57 + "ref": "#closedNamed" 58 + } 59 + }, 60 + "openInlineFieldArray": { 61 + "type": "array", 62 + "items": { 63 + "type": "union", 64 + "refs": ["#a", "#b", "#c"] 65 + } 66 + }, 67 + "closedInlineFieldArray": { 68 + "type": "array", 69 + "items": { 70 + "type": "union", 71 + "refs": ["#a", "#b", "#c"], 72 + "closed": true 73 + } 74 + } 75 + } 76 + } 77 + } 78 + }
-21
packages/emitter/test/scenarios/union-member-refs/input/test/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace test.defs { 4 - union Preferences { 5 - SettingA, 6 - SettingB, 7 - } 8 - 9 - model SettingA { 10 - @maxItems(10) 11 - items?: test.defs.Item[]; 12 - } 13 - 14 - model SettingB { 15 - @required enabled: boolean; 16 - } 17 - 18 - model Item { 19 - @required id: string; 20 - } 21 - }
-44
packages/emitter/test/scenarios/union-member-refs/output/test/defs.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "test.defs", 4 - "defs": { 5 - "preferences": { 6 - "type": "array", 7 - "items": { 8 - "type": "union", 9 - "refs": ["#settingA", "#settingB"] 10 - } 11 - }, 12 - "settingA": { 13 - "type": "object", 14 - "properties": { 15 - "items": { 16 - "type": "array", 17 - "maxLength": 10, 18 - "items": { 19 - "type": "ref", 20 - "ref": "#item" 21 - } 22 - } 23 - } 24 - }, 25 - "settingB": { 26 - "type": "object", 27 - "required": ["enabled"], 28 - "properties": { 29 - "enabled": { 30 - "type": "boolean" 31 - } 32 - } 33 - }, 34 - "item": { 35 - "type": "object", 36 - "required": ["id"], 37 - "properties": { 38 - "id": { 39 - "type": "string" 40 - } 41 - } 42 - } 43 - } 44 - }
+1 -1
packages/emitter/test/scenarios/union-same-ns/input/app/bsky/feed/defs.tsp
··· 24 24 @required post: PostView; 25 25 26 26 @doc("Parent post (may be not found or blocked)") 27 - parent?: PostView | NotFoundPost | BlockedPost; 27 + parent?: PostView | NotFoundPost | BlockedPost | unknown; 28 28 } 29 29 }
+5 -5
packages/example/src/lexicons.ts
··· 85 85 key: 'tid', 86 86 record: { 87 87 type: 'object', 88 + description: 'A follow relationship', 88 89 required: ['subject', 'createdAt'], 89 90 properties: { 90 91 subject: { ··· 98 99 }, 99 100 }, 100 101 }, 101 - description: 'A follow relationship', 102 102 }, 103 103 }, 104 104 }, ··· 111 111 key: 'tid', 112 112 record: { 113 113 type: 'object', 114 + description: 'A like on a post', 114 115 required: ['subject', 'createdAt'], 115 116 properties: { 116 117 subject: { ··· 125 126 }, 126 127 }, 127 128 }, 128 - description: 'A like on a post', 129 129 }, 130 130 }, 131 131 }, ··· 138 138 key: 'tid', 139 139 record: { 140 140 type: 'object', 141 + description: 'A post in the feed', 141 142 required: ['text', 'createdAt'], 142 143 properties: { 143 144 text: { ··· 171 172 }, 172 173 }, 173 174 }, 174 - description: 'A post in the feed', 175 175 }, 176 176 }, 177 177 }, ··· 184 184 key: 'self', 185 185 record: { 186 186 type: 'object', 187 + description: 'User profile information', 187 188 properties: { 188 189 displayName: { 189 190 type: 'string', ··· 203 204 }, 204 205 }, 205 206 }, 206 - description: 'User profile information', 207 207 }, 208 208 }, 209 209 }, ··· 216 216 key: 'tid', 217 217 record: { 218 218 type: 'object', 219 + description: 'A repost of another post', 219 220 required: ['subject', 'createdAt'], 220 221 properties: { 221 222 subject: { ··· 230 231 }, 231 232 }, 232 233 }, 233 - description: 'A repost of another post', 234 234 }, 235 235 }, 236 236 },
+1
packages/example/src/types/app/example/follow.ts
··· 10 10 validate = _validate 11 11 const id = 'app.example.follow' 12 12 13 + /** A follow relationship */ 13 14 export interface Record { 14 15 $type: 'app.example.follow' 15 16 /** DID of the account being followed */
+1
packages/example/src/types/app/example/like.ts
··· 11 11 validate = _validate 12 12 const id = 'app.example.like' 13 13 14 + /** A like on a post */ 14 15 export interface Record { 15 16 $type: 'app.example.like' 16 17 subject: AppExampleDefs.PostRef
+1
packages/example/src/types/app/example/post.ts
··· 11 11 validate = _validate 12 12 const id = 'app.example.post' 13 13 14 + /** A post in the feed */ 14 15 export interface Record { 15 16 $type: 'app.example.post' 16 17 /** Post text content */
+1
packages/example/src/types/app/example/profile.ts
··· 10 10 validate = _validate 11 11 const id = 'app.example.profile' 12 12 13 + /** User profile information */ 13 14 export interface Record { 14 15 $type: 'app.example.profile' 15 16 /** Display name */
+1
packages/example/src/types/app/example/repost.ts
··· 11 11 validate = _validate 12 12 const id = 'app.example.repost' 13 13 14 + /** A repost of another post */ 14 15 export interface Record { 15 16 $type: 'app.example.repost' 16 17 subject: AppExampleDefs.PostRef