An experimental TypeSpec syntax for Lexicon

wip

+226 -108
+15
packages/emitter/lib/main.tsp
··· 35 } 36 37 /** 38 * AT Protocol format scalars. 39 * These map to Lexicon string types with specific format constraints. 40 */
··· 35 } 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 + 52 + /** 53 * AT Protocol format scalars. 54 * These map to Lexicon string types with specific format constraints. 55 */
+51 -27
packages/emitter/src/emitter.ts
··· 405 if (!unionDef) return; 406 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 let hasModelTypes = false; 411 for (const variant of union.variants.values()) { 412 if (variant.type.kind === "Model") { ··· 417 418 const description = getDoc(this.program, union); 419 420 if (hasModelTypes && unionDef.type === "union") { 421 - // Wrap union of models in array type 422 const arrayDef: any = { 423 type: "array", 424 items: unionDef, ··· 796 if (prop.optional !== true) { 797 // Field is required - check if it has the @required decorator 798 if (!isRequired(this.program, prop)) { 799 - throw new Error( 800 - `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` + 801 `In atproto, required fields are discouraged and must be intentional. ` + 802 `Either add @required to the field or make it optional with "?".`, 803 - ); 804 } 805 required.push(name); 806 } ··· 1101 // Check if this is a named union that should be referenced 1102 // (but not if we're defining the union itself) 1103 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; 1114 } 1115 } 1116 - return refDef; 1117 } 1118 } 1119 ··· 1203 refs: unionRefs, 1204 }; 1205 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; 1211 } 1212 1213 if (prop) { ··· 1517 const itemDef = this.typeToLexiconDefinition(itemType); 1518 1519 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 const arrayDef: LexiconArray = { 1526 type: "array", 1527 items: itemDef,
··· 405 if (!unionDef) return; 406 407 // Check if union contains only models (object types) 408 let hasModelTypes = false; 409 for (const variant of union.variants.values()) { 410 if (variant.type.kind === "Model") { ··· 415 416 const description = getDoc(this.program, union); 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 + 424 if (hasModelTypes && unionDef.type === "union") { 425 + // Wrap open union of models in array type (for Preferences pattern) 426 const arrayDef: any = { 427 type: "array", 428 items: unionDef, ··· 800 if (prop.optional !== true) { 801 // Field is required - check if it has the @required decorator 802 if (!isRequired(this.program, prop)) { 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. ` + 807 `In atproto, required fields are discouraged and must be intentional. ` + 808 `Either add @required to the field or make it optional with "?".`, 809 + target: model, 810 + severity: "error", 811 + }); 812 } 813 required.push(name); 814 } ··· 1109 // Check if this is a named union that should be referenced 1110 // (but not if we're defining the union itself) 1111 if (!isDefining) { 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 + } 1135 } 1136 + return refDef; 1137 } 1138 } 1139 } 1140 ··· 1224 refs: unionRefs, 1225 }; 1226 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 + } 1240 } 1241 1242 if (prop) { ··· 1546 const itemDef = this.typeToLexiconDefinition(itemType); 1547 1548 if (itemDef) { 1549 const arrayDef: LexiconArray = { 1550 type: "array", 1551 items: itemDef,
+2 -2
packages/emitter/src/types.ts
··· 6 defs: Record<string, LexiconDefinition>; 7 } 8 9 - export type LexiconDefinition = 10 | LexiconRecord 11 | LexiconQuery 12 | LexiconProcedure ··· 150 export interface LexiconXrpcError { 151 name: string; 152 description?: string; 153 - }
··· 6 defs: Record<string, LexiconDefinition>; 7 } 8 9 + export type LexiconDefinition = 10 | LexiconRecord 11 | LexiconQuery 12 | LexiconProcedure ··· 150 export interface LexiconXrpcError { 151 name: string; 152 description?: string; 153 + }
+1
packages/emitter/test/scenarios/atproto/input/app/bsky/actor/defs.tsp
··· 347 | app.bsky.feed.threadgate.FollowerRule 348 | app.bsky.feed.threadgate.FollowingRule 349 | app.bsky.feed.threadgate.ListRule 350 )[]; 351 352 @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.")
··· 347 | app.bsky.feed.threadgate.FollowerRule 348 | app.bsky.feed.threadgate.FollowingRule 349 | app.bsky.feed.threadgate.ListRule 350 + | unknown 351 )[]; 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 | app.bsky.feed.defs.BlockedPost 21 | app.bsky.feed.defs.NotFoundPost 22 | app.bsky.feed.defs.PostView 23 ); 24 } 25 }
··· 20 | app.bsky.feed.defs.BlockedPost 21 | app.bsky.feed.defs.NotFoundPost 22 | app.bsky.feed.defs.PostView 23 + | unknown 24 ); 25 } 26 }
+2
packages/emitter/test/scenarios/atproto/input/app/bsky/embed/record.tsp
··· 17 | app.bsky.graph.defs.ListView 18 | app.bsky.labeler.defs.LabelerView 19 | app.bsky.graph.defs.StarterPackViewBasic 20 ); 21 } 22 ··· 41 | app.bsky.embed.external.View 42 | app.bsky.embed.record.View 43 | app.bsky.embed.recordWithMedia.View 44 )[]; 45 46 @required indexedAt: datetime;
··· 17 | app.bsky.graph.defs.ListView 18 | app.bsky.labeler.defs.LabelerView 19 | app.bsky.graph.defs.StarterPackViewBasic 20 + | unknown 21 ); 22 } 23 ··· 42 | app.bsky.embed.external.View 43 | app.bsky.embed.record.View 44 | app.bsky.embed.recordWithMedia.View 45 + | unknown 46 )[]; 47 48 @required indexedAt: datetime;
+2
packages/emitter/test/scenarios/atproto/input/app/bsky/embed/recordWithMedia.tsp
··· 10 | app.bsky.embed.images.Main 11 | app.bsky.embed.video.Main 12 | app.bsky.embed.external.Main 13 ); 14 } 15 ··· 21 | app.bsky.embed.images.View 22 | app.bsky.embed.video.View 23 | app.bsky.embed.external.View 24 ); 25 } 26 }
··· 10 | app.bsky.embed.images.Main 11 | app.bsky.embed.video.Main 12 | app.bsky.embed.external.Main 13 + | unknown 14 ); 15 } 16 ··· 22 | app.bsky.embed.images.View 23 | app.bsky.embed.video.View 24 | app.bsky.embed.external.View 25 + | unknown 26 ); 27 } 28 }
+5 -4
packages/emitter/test/scenarios/atproto/input/app/bsky/feed/defs.tsp
··· 13 | app.bsky.embed.external.View 14 | app.bsky.embed.record.View 15 | app.bsky.embed.recordWithMedia.View 16 ); 17 18 bookmarkCount?: integer; ··· 48 @required post: PostView; 49 50 reply?: ReplyRef; 51 - reason?: (ReasonRepost | ReasonPin); 52 53 @doc("Context provided by feed generator that may be passed back alongside interactions.") 54 @maxLength(2000) ··· 60 } 61 62 model ReplyRef { 63 - @required root: (PostView | NotFoundPost | BlockedPost); 64 - @required parent: (PostView | NotFoundPost | BlockedPost); 65 66 @doc("When parent is a reply to another post, this is the author of that post.") 67 grandparentAuthor?: app.bsky.actor.defs.ProfileViewBasic; ··· 144 model SkeletonFeedPost { 145 @required post: atUri; 146 147 - reason?: (SkeletonReasonRepost | SkeletonReasonPin); 148 149 @doc("Context that will be passed through to client and may be passed to feed generator back alongside interactions.") 150 @maxLength(2000)
··· 13 | app.bsky.embed.external.View 14 | app.bsky.embed.record.View 15 | app.bsky.embed.recordWithMedia.View 16 + | unknown 17 ); 18 19 bookmarkCount?: integer; ··· 49 @required post: PostView; 50 51 reply?: ReplyRef; 52 + reason?: (ReasonRepost | ReasonPin | unknown); 53 54 @doc("Context provided by feed generator that may be passed back alongside interactions.") 55 @maxLength(2000) ··· 61 } 62 63 model ReplyRef { 64 + @required root: (PostView | NotFoundPost | BlockedPost | unknown); 65 + @required parent: (PostView | NotFoundPost | BlockedPost | unknown); 66 67 @doc("When parent is a reply to another post, this is the author of that post.") 68 grandparentAuthor?: app.bsky.actor.defs.ProfileViewBasic; ··· 145 model SkeletonFeedPost { 146 @required post: atUri; 147 148 + reason?: (SkeletonReasonRepost | SkeletonReasonPin | unknown); 149 150 @doc("Context that will be passed through to client and may be passed to feed generator back alongside interactions.") 151 @maxLength(2000)
+1
packages/emitter/test/scenarios/atproto/input/app/bsky/feed/post.tsp
··· 24 | app.bsky.embed.external.Main 25 | app.bsky.embed.record.Main 26 | app.bsky.embed.recordWithMedia.Main 27 ); 28 29 @doc("Indicates human language of post primary text content.")
··· 24 | app.bsky.embed.external.Main 25 | app.bsky.embed.record.Main 26 | app.bsky.embed.recordWithMedia.Main 27 + | unknown 28 ); 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 @doc("Indicates that the 'swapCommit' parameter did not match current commit.") 5 model InvalidSwap {} 6 7 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.") 8 @procedure 9 @errors(InvalidSwap) ··· 16 validate?: boolean; 17 18 @required 19 - @closed 20 - writes: (Create | Update | Delete)[]; 21 22 @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 swapCommit?: cid; 24 }): { 25 commit?: com.atproto.repo.defs.CommitMeta; 26 27 - @closed 28 - results?: (CreateResult | UpdateResult | DeleteResult)[]; 29 }; 30 31 @doc("Operation which creates a new record.")
··· 4 @doc("Indicates that the 'swapCommit' parameter did not match current commit.") 5 model InvalidSwap {} 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 + 21 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.") 22 @procedure 23 @errors(InvalidSwap) ··· 30 validate?: boolean; 31 32 @required 33 + writes: Write[]; 34 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.") 36 swapCommit?: cid; 37 }): { 38 commit?: com.atproto.repo.defs.CommitMeta; 39 40 + results?: Result[]; 41 }; 42 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 @required post: PostView; 25 26 @doc("Parent post (may be not found or blocked)") 27 - parent?: PostView | NotFoundPost | BlockedPost; 28 } 29 }
··· 24 @required post: PostView; 25 26 @doc("Parent post (may be not found or blocked)") 27 + parent?: PostView | NotFoundPost | BlockedPost | unknown; 28 } 29 }
+5 -5
packages/example/src/lexicons.ts
··· 85 key: 'tid', 86 record: { 87 type: 'object', 88 required: ['subject', 'createdAt'], 89 properties: { 90 subject: { ··· 98 }, 99 }, 100 }, 101 - description: 'A follow relationship', 102 }, 103 }, 104 }, ··· 111 key: 'tid', 112 record: { 113 type: 'object', 114 required: ['subject', 'createdAt'], 115 properties: { 116 subject: { ··· 125 }, 126 }, 127 }, 128 - description: 'A like on a post', 129 }, 130 }, 131 }, ··· 138 key: 'tid', 139 record: { 140 type: 'object', 141 required: ['text', 'createdAt'], 142 properties: { 143 text: { ··· 171 }, 172 }, 173 }, 174 - description: 'A post in the feed', 175 }, 176 }, 177 }, ··· 184 key: 'self', 185 record: { 186 type: 'object', 187 properties: { 188 displayName: { 189 type: 'string', ··· 203 }, 204 }, 205 }, 206 - description: 'User profile information', 207 }, 208 }, 209 }, ··· 216 key: 'tid', 217 record: { 218 type: 'object', 219 required: ['subject', 'createdAt'], 220 properties: { 221 subject: { ··· 230 }, 231 }, 232 }, 233 - description: 'A repost of another post', 234 }, 235 }, 236 },
··· 85 key: 'tid', 86 record: { 87 type: 'object', 88 + description: 'A follow relationship', 89 required: ['subject', 'createdAt'], 90 properties: { 91 subject: { ··· 99 }, 100 }, 101 }, 102 }, 103 }, 104 }, ··· 111 key: 'tid', 112 record: { 113 type: 'object', 114 + description: 'A like on a post', 115 required: ['subject', 'createdAt'], 116 properties: { 117 subject: { ··· 126 }, 127 }, 128 }, 129 }, 130 }, 131 }, ··· 138 key: 'tid', 139 record: { 140 type: 'object', 141 + description: 'A post in the feed', 142 required: ['text', 'createdAt'], 143 properties: { 144 text: { ··· 172 }, 173 }, 174 }, 175 }, 176 }, 177 }, ··· 184 key: 'self', 185 record: { 186 type: 'object', 187 + description: 'User profile information', 188 properties: { 189 displayName: { 190 type: 'string', ··· 204 }, 205 }, 206 }, 207 }, 208 }, 209 }, ··· 216 key: 'tid', 217 record: { 218 type: 'object', 219 + description: 'A repost of another post', 220 required: ['subject', 'createdAt'], 221 properties: { 222 subject: { ··· 231 }, 232 }, 233 }, 234 }, 235 }, 236 },
+1
packages/example/src/types/app/example/follow.ts
··· 10 validate = _validate 11 const id = 'app.example.follow' 12 13 export interface Record { 14 $type: 'app.example.follow' 15 /** DID of the account being followed */
··· 10 validate = _validate 11 const id = 'app.example.follow' 12 13 + /** A follow relationship */ 14 export interface Record { 15 $type: 'app.example.follow' 16 /** DID of the account being followed */
+1
packages/example/src/types/app/example/like.ts
··· 11 validate = _validate 12 const id = 'app.example.like' 13 14 export interface Record { 15 $type: 'app.example.like' 16 subject: AppExampleDefs.PostRef
··· 11 validate = _validate 12 const id = 'app.example.like' 13 14 + /** A like on a post */ 15 export interface Record { 16 $type: 'app.example.like' 17 subject: AppExampleDefs.PostRef
+1
packages/example/src/types/app/example/post.ts
··· 11 validate = _validate 12 const id = 'app.example.post' 13 14 export interface Record { 15 $type: 'app.example.post' 16 /** Post text content */
··· 11 validate = _validate 12 const id = 'app.example.post' 13 14 + /** A post in the feed */ 15 export interface Record { 16 $type: 'app.example.post' 17 /** Post text content */
+1
packages/example/src/types/app/example/profile.ts
··· 10 validate = _validate 11 const id = 'app.example.profile' 12 13 export interface Record { 14 $type: 'app.example.profile' 15 /** Display name */
··· 10 validate = _validate 11 const id = 'app.example.profile' 12 13 + /** User profile information */ 14 export interface Record { 15 $type: 'app.example.profile' 16 /** Display name */
+1
packages/example/src/types/app/example/repost.ts
··· 11 validate = _validate 12 const id = 'app.example.repost' 13 14 export interface Record { 15 $type: 'app.example.repost' 16 subject: AppExampleDefs.PostRef
··· 11 validate = _validate 12 const id = 'app.example.repost' 13 14 + /** A repost of another post */ 15 export interface Record { 16 $type: 'app.example.repost' 17 subject: AppExampleDefs.PostRef