An experimental TypeSpec syntax for Lexicon
1# Typelex Docs 2 3This maps [atproto Lexicon](https://atproto.com/specs/lexicon) JSON syntax to typelex (which is a [TypeSpec](https://typespec.io/) emitter). It assumes you're familiar with Lexicon and want to understand how to express it in TypeSpec. Consult [TypeSpec docs](https://typespec.io/) on details of TypeSpec syntax. 4 5This page was mostly written by Claude based on the test fixtures from this repo (which are [deployed in the playground](https://playground.typelex.org/)). I hope it's mostly correct and comprehensible. When in doubt, refer to those fixtures. 6 7## Playground 8 9Go to https://playground.typelex.org/ to play with a bunch of lexicons. 10 11## Quick Start 12 13Every TypeSpec file starts with an import and namespace: 14 15```typescript 16import "@typelex/emitter"; 17 18/** Common definitions used by other lexicons */ 19namespace com.example.defs { 20 // definitions here 21} 22``` 23 24**Maps to:** 25```json 26{ 27 "lexicon": 1, 28 "id": "com.example.defs", 29 "description": "Common definitions used by other lexicons", 30 "defs": { ... } 31} 32``` 33 34Use `/** */` doc comments for descriptions (or `@doc()` decorator as alternative). 35 36## Top-Level Lexicon Types 37 38### Query (XRPC Query) 39 40```typescript 41namespace com.example.getRecord { 42 /** Retrieve a record by ID */ 43 @query 44 op main( 45 /** The record identifier */ 46 @required id: string 47 ): { 48 @required record: com.example.record.Main; 49 }; 50} 51``` 52 53**Maps to:** `{"type": "query", ...}` with `parameters` and `output` 54 55### Procedure (XRPC Procedure) 56 57```typescript 58namespace com.example.createRecord { 59 /** Create a new record */ 60 @procedure 61 op main(input: { 62 @required text: string; 63 }): { 64 @required uri: atUri; 65 @required cid: cid; 66 }; 67} 68``` 69 70**Maps to:** `{"type": "procedure", ...}` with `input` and `output` 71 72### Subscription (XRPC Subscription) 73 74```typescript 75namespace com.example.subscribeRecords { 76 /** Subscribe to record updates */ 77 @subscription 78 op main(cursor?: integer): (Record | Delete); 79 80 model Record { 81 @required uri: atUri; 82 @required record: com.example.record.Main; 83 } 84 85 model Delete { 86 @required uri: atUri; 87 } 88} 89``` 90 91**Maps to:** `{"type": "subscription", ...}` with `message` containing union 92 93### Record 94 95```typescript 96namespace com.example.post { 97 @rec("tid") 98 /** A post record */ 99 model Main { 100 @required text: string; 101 @required createdAt: datetime; 102 } 103} 104``` 105 106**Maps to:** `{"type": "record", "key": "tid", "record": {...}}` 107 108**Record key types:** `@rec("tid")`, `@rec("any")`, `@rec("nsid")` 109 110### Object (Plain Definition) 111 112```typescript 113namespace com.example.defs { 114 /** User metadata */ 115 model Metadata { 116 version?: integer = 1; 117 tags?: string[]; 118 } 119} 120``` 121 122**Maps to:** `{"type": "object", "properties": {...}}` 123 124## Reserved Keywords 125 126Use backticks for TypeScript/TypeSpec reserved words: 127 128```typescript 129namespace app.bsky.feed.post.`record` { ... } 130namespace `pub`.leaflet.subscription { ... } 131``` 132 133## Inline vs Definitions 134 135**By default, models become separate defs.** Use `@inline` to prevent this: 136 137```typescript 138// Without @inline - becomes separate def "statusEnum" 139union StatusEnum { 140 "active", 141 "inactive", 142} 143 144// With @inline - inlined where used 145@inline 146union StatusEnum { 147 "active", 148 "inactive", 149} 150``` 151 152Use `@inline` when you want the type directly embedded rather than referenced. 153 154## Optional vs Required Fields 155 156**In lexicons, optional fields are the norm.** Required fields are discouraged and need explicit `@required`: 157 158```typescript 159model Post { 160 text?: string; // optional (common) 161 @required createdAt: datetime; // required (discouraged, needs decorator) 162} 163``` 164 165**Maps to:** 166```json 167{ 168 "type": "object", 169 "required": ["createdAt"], 170 "properties": { 171 "text": {"type": "string"}, 172 "createdAt": {"type": "string", "format": "datetime"} 173 } 174} 175``` 176 177## Primitive Types 178 179| TypeSpec | Lexicon JSON | 180|----------|--------------| 181| `boolean` | `{"type": "boolean"}` | 182| `integer` | `{"type": "integer"}` | 183| `string` | `{"type": "string"}` | 184| `bytes` | `{"type": "bytes"}` | 185| `cidLink` | `{"type": "cid-link"}` | 186| `unknown` | `{"type": "unknown"}` | 187 188## Format Types 189 190Specialized string formats: 191 192| TypeSpec | Lexicon Format | 193|----------|----------------| 194| `atIdentifier` | `at-identifier` - Handle or DID | 195| `atUri` | `at-uri` - AT Protocol URI | 196| `cid` | `cid` - Content ID | 197| `datetime` | `datetime` - ISO 8601 datetime | 198| `did` | `did` - DID identifier | 199| `handle` | `handle` - Handle identifier | 200| `nsid` | `nsid` - Namespaced ID | 201| `tid` | `tid` - Timestamp ID | 202| `recordKey` | `record-key` - Record key | 203| `uri` | `uri` - Generic URI | 204| `language` | `language` - Language tag | 205 206## Unions 207 208### Open Unions (Common Pattern) 209 210**Open unions are the default and preferred in lexicons.** Add `unknown` to mark as open: 211 212```typescript 213model Main { 214 /** Can be any of these types or future additions */ 215 @required item: TypeA | TypeB | TypeC | unknown; 216} 217 218model TypeA { 219 @readOnly @required kind: string = "a"; 220 @required valueA: string; 221} 222``` 223 224**Maps to:** 225```json 226{ 227 "properties": { 228 "item": { 229 "type": "union", 230 "refs": ["#typeA", "#typeB", "#typeC"] 231 } 232 } 233} 234``` 235 236The `unknown` makes it open but doesn't appear in refs. 237 238### Known Values (Open String Enum) 239 240Suggest values but allow others: 241 242```typescript 243model Main { 244 /** Language - suggests common values but allows any */ 245 lang?: "en" | "es" | "fr" | string; 246} 247``` 248 249**Maps to:** 250```json 251{ 252 "properties": { 253 "lang": { 254 "type": "string", 255 "knownValues": ["en", "es", "fr"] 256 } 257 } 258} 259``` 260 261### Closed Unions (Discouraged) 262 263**⚠️ Closed unions are discouraged in lexicons** as they prevent future additions. Use only when absolutely necessary: 264 265```typescript 266@closed 267@inline 268union Action { 269 Create, 270 Update, 271 Delete, 272} 273 274model Main { 275 @required action: Action; 276} 277``` 278 279**Maps to:** 280```json 281{ 282 "properties": { 283 "action": { 284 "type": "union", 285 "refs": ["#create", "#update", "#delete"], 286 "closed": true 287 } 288 } 289} 290``` 291 292### Closed Enums (Discouraged) 293 294**⚠️ Closed enums are also discouraged.** Use `@closed @inline union` for fixed value sets: 295 296```typescript 297@closed 298@inline 299union Status { 300 "active", 301 "inactive", 302 "pending", 303} 304``` 305 306**Maps to:** 307```json 308{ 309 "type": "string", 310 "enum": ["active", "inactive", "pending"] 311} 312``` 313 314Integer enums work the same way: 315 316```typescript 317@closed 318@inline 319union Fibonacci { 320 1, 2, 3, 5, 8, 321} 322``` 323 324## Arrays 325 326Use `[]` suffix: 327 328```typescript 329model Main { 330 /** Array of strings */ 331 stringArray?: string[]; 332 333 /** Array with size constraints */ 334 @minItems(1) 335 @maxItems(10) 336 limitedArray?: integer[]; 337 338 /** Array of references */ 339 items?: Item[]; 340 341 /** Array of union types */ 342 mixed?: (TypeA | TypeB | unknown)[]; 343} 344``` 345 346**Maps to:** `{"type": "array", "items": {...}}` 347 348**Note:** `@minItems`/`@maxItems` in TypeSpec map to `minLength`/`maxLength` in JSON. 349 350## Blobs 351 352```typescript 353model Main { 354 /** Basic blob */ 355 file?: Blob; 356 357 /** Image up to 5MB */ 358 image?: Blob<#["image/*"], 5000000>; 359 360 /** Specific types up to 2MB */ 361 photo?: Blob<#["image/png", "image/jpeg"], 2000000>; 362} 363``` 364 365**Maps to:** 366```json 367{ 368 "file": {"type": "blob"}, 369 "image": { 370 "type": "blob", 371 "accept": ["image/*"], 372 "maxSize": 5000000 373 } 374} 375``` 376 377## References 378 379### Local References 380 381Same namespace, uses `#`: 382 383```typescript 384model Main { 385 metadata?: Metadata; 386} 387 388model Metadata { 389 @required key: string; 390} 391``` 392 393**Maps to:** `{"type": "ref", "ref": "#metadata"}` 394 395### External References 396 397Different namespace to specific def: 398 399```typescript 400model Main { 401 externalRef?: com.example.defs.Metadata; 402} 403``` 404 405**Maps to:** `{"type": "ref", "ref": "com.example.defs#metadata"}` 406 407Different namespace to main def (no fragment): 408 409```typescript 410model Main { 411 mainRef?: com.example.post.Main; 412} 413``` 414 415**Maps to:** `{"type": "ref", "ref": "com.example.post"}` 416 417## Tokens 418 419Empty models marked with `@token`: 420 421```typescript 422/** Indicates spam content */ 423@token 424model ReasonSpam {} 425 426/** Indicates policy violation */ 427@token 428model ReasonViolation {} 429 430model Report { 431 @required reason: (ReasonSpam | ReasonViolation | unknown); 432} 433``` 434 435**Maps to:** 436```json 437{ 438 "report": { 439 "properties": { 440 "reason": { 441 "type": "union", 442 "refs": ["#reasonSpam", "#reasonViolation"] 443 } 444 } 445 }, 446 "reasonSpam": { 447 "type": "token", 448 "description": "Indicates spam content" 449 } 450} 451``` 452 453## Operation Details 454 455### Query Parameters 456 457```typescript 458@query 459op main( 460 @required search: string, 461 limit?: integer = 50, 462 tags?: string[] 463): { ... }; 464``` 465 466Parameters can be inline with decorators before each. 467 468### Procedure with Input and Parameters 469 470```typescript 471@procedure 472op main( 473 input: { 474 @required data: string; 475 }, 476 parameters: { 477 @required repo: atIdentifier; 478 validate?: boolean = true; 479 } 480): { ... }; 481``` 482 483Use `input:` for body, `parameters:` for query params. 484 485### No Output 486 487```typescript 488@procedure 489op main(input: { 490 @required uri: atUri; 491}): void; 492``` 493 494Use `: void` for procedures with no output. 495 496### Output Without Schema 497 498```typescript 499@query 500@encoding("application/json") 501op main(id?: string): never; 502``` 503 504Use `: never` with `@encoding()` for output with encoding but no schema. 505 506### Errors 507 508```typescript 509/** The provided text is invalid */ 510model InvalidText {} 511 512/** User not found */ 513model NotFound {} 514 515@procedure 516@errors(InvalidText, NotFound) 517op main(...): ...; 518``` 519 520Empty models with descriptions become error definitions. 521 522## Constraints 523 524### String Constraints 525 526```typescript 527model Main { 528 /** Byte length constraints */ 529 @minLength(1) 530 @maxLength(100) 531 text?: string; 532 533 /** Grapheme cluster length constraints */ 534 @minGraphemes(1) 535 @maxGraphemes(50) 536 displayName?: string; 537} 538``` 539 540**Maps to:** `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes` 541 542### Integer Constraints 543 544```typescript 545model Main { 546 @minValue(1) 547 @maxValue(100) 548 score?: integer; 549} 550``` 551 552**Maps to:** `minimum`/`maximum` 553 554### Bytes Constraints 555 556```typescript 557model Main { 558 @minBytes(1) 559 @maxBytes(1024) 560 data?: bytes; 561} 562``` 563 564**Maps to:** `minLength`/`maxLength` 565 566**Note:** Use `@minBytes`/`@maxBytes` in TypeSpec, but they map to `minLength`/`maxLength` in JSON. 567 568### Array Constraints 569 570```typescript 571model Main { 572 @minItems(1) 573 @maxItems(10) 574 items?: string[]; 575} 576``` 577 578**Maps to:** `minLength`/`maxLength` 579 580**Note:** Use `@minItems`/`@maxItems` in TypeSpec, but they map to `minLength`/`maxLength` in JSON. 581 582## Default and Constant Values 583 584### Defaults 585 586```typescript 587model Main { 588 version?: integer = 1; 589 lang?: string = "en"; 590} 591``` 592 593**Maps to:** `{"default": 1}`, `{"default": "en"}` 594 595### Constants 596 597Use `@readOnly` with default value: 598 599```typescript 600model Main { 601 @readOnly status?: string = "active"; 602} 603``` 604 605**Maps to:** `{"const": "active"}` 606 607## Nullable Fields 608 609Use `| null` for nullable fields: 610 611```typescript 612model Main { 613 @required createdAt: datetime; 614 updatedAt?: datetime | null; // can be omitted or null 615 deletedAt?: datetime; // can only be omitted 616} 617``` 618 619**Maps to:** 620```json 621{ 622 "required": ["createdAt"], 623 "nullable": ["updatedAt"], 624 "properties": { ... } 625} 626``` 627 628## Common Patterns 629 630### Discriminated Unions 631 632Use `@readOnly` with const for discriminator: 633 634```typescript 635model Create { 636 @readOnly @required type: string = "create"; 637 @required data: string; 638} 639 640model Update { 641 @readOnly @required type: string = "update"; 642 @required id: string; 643} 644``` 645 646### Nested Unions 647 648```typescript 649model Container { 650 @required id: string; 651 @required payload: (PayloadA | PayloadB | unknown); 652} 653``` 654 655Unions can be nested in objects and arrays. 656 657## Naming Conventions 658 659Model names convert from PascalCase to camelCase in defs: 660 661```typescript 662model StatusEnum { ... } // becomes "statusEnum" 663model UserMetadata { ... } // becomes "userMetadata" 664model Main { ... } // becomes "main" 665``` 666 667## Decorator Style 668 669- Single `@required` goes on same line: `@required text: string` 670- Multiple decorators go on separate lines with blank line after: 671 ```typescript 672 @minLength(1) 673 @maxLength(100) 674 text?: string; 675 ```