An experimental TypeSpec syntax for Lexicon
1# typelex docs 2 3Typelex is a [TypeSpec](https://typespec.io/) emitter that outputs [AT Lexicon](https://atproto.com/specs/lexicon) JSON files. 4 5## Introduction 6 7### What's Lexicon? 8 9[Lexicon](https://atproto.com/specs/lexicon) is a schema format used by [AT](https://atproto.com/) applications. Here's a small example: 10 11```json 12{ 13 "lexicon": 1, 14 "id": "app.bsky.bookmark.defs", 15 "defs": { 16 "listItemView": { 17 "type": "object", 18 "properties": { 19 "uri": { "type": "string", "format": "at-uri" } 20 }, 21 "required": ["uri"] 22 } 23 } 24} 25``` 26 27This schema is then used to generate code for parsing of these objects, their validation, and their types. 28 29### What's TypeSpec? 30 31[TypeSpec](https://typespec.io/) is a TypeScript-like language for writing schemas for data and API calls. It offers flexible syntax and tooling (like LSP), but doesn't specify output format—that's what *emitters* do. For example, there's a [JSON Schema emitter](https://typespec.io/docs/emitters/json-schema/reference/) and a [Protobuf emitter](https://typespec.io/docs/emitters/protobuf/reference/). 32 33### What's typelex? 34 35Typelex is a TypeSpec emitter targeting Lexicon. Here's the same schema in TypeSpec: 36 37```typescript 38import "@typelex/emitter"; 39 40namespace app.bsky.bookmark.defs { 41 model ListItemView { 42 @required uri: atUri; 43 } 44} 45``` 46 47Run the compiler, and it generates Lexicon JSON for you. 48 49The JSON is what you'll publish—typelex just makes authoring easier. Think of it as "CoffeeScript for Lexicon" (however terrible that may be). 50 51Typelex tries to stay faithful to both TypeSpec idioms and Lexicon concepts, which is a tricky balance. Since we can't add keywords to TypeSpec, decorators fill the gaps—you'll write `@procedure op` instead of `procedure`, or `model` for what Lexicon calls a "def". One downside of this approach is you'll need to learn both Lexicon *and* TypeSpec to know what you're doing. Scan the [TypeSpec language overview](https://typespec.io/docs/language-basics/overview/) to get a feel for it. 52 53Personally, I find JSON Lexicons hard to read and author, so the tradeoff works for me. 54 55### Playground 56 57[Open Playground](https://playground.typelex.org/) to play with a bunch of lexicons and to see how typelex code translates to Lexicon JSON. 58 59If you already know Lexicon, you can learn the syntax by just opening the Lexicons you're familiar with. 60 61## Quick Start 62 63### Namespaces 64 65A namespace corresponds to a Lexicon file: 66 67```typescript 68import "@typelex/emitter"; 69 70namespace app.bsky.feed.defs { 71 model PostView { 72 // ... 73 } 74} 75``` 76 77This emits `app/bsky/feed/defs.json`: 78 79```json 80{ 81 "lexicon": 1, 82 "id": "app.bsky.feed.defs", 83 "defs": { ... } 84} 85``` 86 87[Try it in the playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5mZWVkLmRlZnMgewogIG1vZGVsIFBvc3RWaWV3xRMgIHJlcGx5Q291bnQ%2FOiBpbnRlZ2VyO8gab3N01RtsaWtl0xl9Cn0K&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D). 88 89If TypeSpec complains about reserved words in namespaces, use backticks: 90 91```typescript 92import "@typelex/emitter"; 93 94namespace app.bsky.feed.post.`record` { } 95namespace `pub`.blocks.blockquote { } 96``` 97 98You can define multiple namespaces in one file: 99 100```typescript 101import "@typelex/emitter"; 102 103namespace com.example.foo { 104 model Main { /* ... */ } 105} 106 107namespace com.example.bar { 108 model Main { /* ... */ } 109} 110``` 111 112This emits two files: `com/example/foo.json` and `com/example/bar.json`. 113 114You can `import` other `.tsp` files to reuse definitions. Output structure is determined solely by namespaces, not by source file organization. 115 116## Models 117 118By default, **every `model` becomes a Lexicon definition**: 119 120```typescript 121import "@typelex/emitter"; 122 123namespace app.bsky.feed.defs { 124 model PostView { /* ... */ } 125 model ViewerState { /* ... */ } 126} 127``` 128 129Model names convert PascalCase → camelCase. For example, `PostView` becomes `postView`: 130 131```json 132{ 133 "id": "app.bsky.feed.defs", 134 "defs": { 135 "postView": { /* ... */ }, 136 "viewerState": { /* ... */ } 137 } 138 // ... 139} 140``` 141 142Models in the same namespace can be in separate `namespace` blocks or even different files (via [`import`](https://typespec.io/docs/language-basics/imports/)). TypeSpec bundles them all into one Lexicon file per namespace. 143 144### Namespace Conventions: `.defs` vs `Main` 145 146By convention, a namespace must either end with `.defs` or have a `Main` model. 147 148Use `.defs` for a grabbag of reusable definitions: 149 150```typescript 151import "@typelex/emitter"; 152 153namespace app.bsky.feed.defs { 154 model PostView { /* ... */ } 155 model ViewerState { /* ... */ } 156} 157``` 158 159For a Lexicon about one main concept, add a `Main` model instead: 160 161```typescript 162import "@typelex/emitter"; 163 164namespace app.bsky.embed.video { 165 model Main { /* ... */ } 166 model Caption { /* ... */ } 167} 168``` 169 170Pick one or the other—the compiler will error if you don't. 171 172### References 173 174Models can reference other models: 175 176```typescript 177import "@typelex/emitter"; 178 179namespace app.bsky.embed.video { 180 model Main { 181 captions?: Caption[]; 182 } 183 model Caption { /* ... */ } 184} 185``` 186 187This becomes a `ref` to the `caption` definition in the same file: 188 189```json 190// ... 191"defs": { 192 "main": { 193 // ... 194 "properties": { 195 "captions": { 196 // ... 197 "items": { "type": "ref", "ref": "#caption" } 198 } 199 } 200 }, 201 "caption": { 202 "type": "object", 203 "properties": {} 204 } 205// ... 206``` 207 208You can also reference models from other namespaces: 209 210```typescript 211import "@typelex/emitter"; 212 213namespace app.bsky.actor.profile { 214 model Main { 215 labels?: (com.atproto.label.defs.SelfLabels | unknown); 216 } 217} 218 219namespace com.atproto.label.defs { 220 model SelfLabels { /* ... */ } 221} 222``` 223 224This becomes a fully qualified reference to another Lexicon: 225 226```json 227// ... 228"labels": { 229 "type": "union", 230 "refs": ["com.atproto.label.defs#selfLabels"] 231} 232// ... 233``` 234 235([See it in the Playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5hY3Rvci5wcm9maWxlIHsKICBAcmVjKCJsaXRlcmFsOnNlbGYiKQogIG1vZGVsIE1haW7FJiAgZGlzcGxheU5hbWU%2FOiBzdHJpbmc7xRpsYWJlbHM%2FOiAoY29tLmF0cHJvdG8uxRYuZGVmcy5TZWxmTMUlIHwgdW5rbm93binEPH0KfewAptY%2F5QCA5gCPy0nEFSAgLy8gLi4uxko%3D&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D).) 236 237This works across files too—just remember to `import` the file with the definition. 238 239### External Stubs 240 241If you don't have TypeSpec definitions for external Lexicons, you can stub them out using the `@external` decorator: 242 243```typescript 244import "@typelex/emitter"; 245 246namespace app.bsky.actor.profile { 247 model Main { 248 labels?: (com.atproto.label.defs.SelfLabels | unknown); 249 } 250} 251 252// Empty stub (like .d.ts in TypeScript) 253@external 254namespace com.atproto.label.defs { 255 model SelfLabels { } 256} 257``` 258 259The `@external` decorator tells the emitter to skip JSON output for that namespace. This is useful when referencing definitions from other Lexicons that you don't want to re-emit. 260 261You could collect external stubs in one file and import them: 262 263```typescript 264import "@typelex/emitter"; 265import "../atproto-stubs.tsp"; 266 267namespace app.bsky.actor.profile { 268 model Main { 269 labels?: (com.atproto.label.defs.SelfLabels | unknown); 270 } 271} 272``` 273 274Then in `atproto-stubs.tsp`: 275 276```typescript 277import "@typelex/emitter"; 278 279@external 280namespace com.atproto.label.defs { 281 model SelfLabels { } 282} 283 284@external 285namespace com.atproto.repo.defs { 286 model StrongRef { } 287 @token model SomeToken { } // Note: Tokens still need @token 288} 289// ... more stubs 290``` 291 292You'll want to ensure the real JSON for external Lexicons is available before running codegen. 293 294### Inline Models 295 296By default, every `model` becomes a top-level def: 297 298```typescript 299import "@typelex/emitter"; 300 301namespace app.bsky.embed.video { 302 model Main { 303 captions?: Caption[]; 304 } 305 model Caption { /* ... */ } 306} 307``` 308 309This creates two defs: `main` and `caption`. 310 311Use `@inline` to expand a model inline instead: 312 313```typescript 314import "@typelex/emitter"; 315 316namespace app.bsky.embed.video { 317 model Main { 318 captions?: Caption[]; 319 } 320 321 @inline 322 model Caption { 323 text?: string 324 } 325} 326``` 327 328Now `Caption` is expanded inline: 329 330```json 331// ... 332"captions": { 333 "type": "array", 334 "items": { 335 "type": "object", 336 "properties": { "text": { "type": "string" } } 337 } 338} 339// ... 340``` 341 342Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. 343 344## Top-Level Lexicon Types 345 346TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. 347 348### Objects 349 350A plain `model` becomes a Lexicon object: 351 352```typescript 353import "@typelex/emitter"; 354 355namespace com.example.post { 356 model Main { /* ... */ } 357} 358``` 359 360Output: 361 362```json 363// ... 364"main": { 365 "type": "object", 366 "properties": { /* ... */ } 367} 368// ... 369``` 370 371### Records 372 373Use `@rec` to make a model a Lexicon record: 374 375```typescript 376import "@typelex/emitter"; 377 378namespace com.example.post { 379 @rec("tid") 380 model Main { /* ... */ } 381} 382``` 383 384Output: 385 386```json 387// ... 388"main": { 389 "type": "record", 390 "key": "tid", 391 "record": { "type": "object", "properties": { /* ... */ } } 392} 393// ... 394``` 395 396You can pass any [Record Key Type](https://atproto.com/specs/record-key): `@rec("tid")`, `@rec("nsid")`, `@rec("literal:self")`, etc. 397 398(It's `@rec` not `@record` because "record" is reserved in TypeSpec.) 399 400### Queries 401 402In TypeSpec, use [`op`](https://typespec.io/docs/language-basics/operations/) for functions. Mark with `@query` for queries: 403 404```typescript 405import "@typelex/emitter"; 406 407namespace com.atproto.repo.getRecord { 408 @query 409 op main( 410 @required repo: atIdentifier, 411 @required collection: nsid, 412 @required rkey: recordKey, 413 cid?: cid 414 ): { 415 @required uri: atUri; 416 cid?: cid; 417 }; 418} 419``` 420 421Arguments become `parameters`, return type becomes `output`: 422 423```json 424// ... 425"main": { 426 "type": "query", 427 "parameters": { 428 "type": "params", 429 "properties": { 430 "repo": { /* ... */ }, 431 "collection": { /* ... */ }, 432 // ... 433 }, 434 "required": ["repo", "collection", "rkey"] 435 }, 436 "output": { 437 "encoding": "application/json", 438 "schema": { 439 "type": "object", 440 "properties": { 441 "uri": { /* ... */ }, 442 "cid": { /* ... */ } 443 }, 444 "required": ["uri"] 445 } 446 } 447} 448// ... 449``` 450 451`encoding` defaults to `"application/json"`. Override with `@encoding("foo/bar")` on the `op`. 452 453Declare errors with `@errors`: 454 455```typescript 456import "@typelex/emitter"; 457 458namespace com.atproto.repo.getRecord { 459 @query 460 @errors(FooError, BarError) 461 op main(/* ... */): { /* ... */ }; 462 463 model FooError {} 464 model BarError {} 465} 466``` 467 468You can extract the `{ /* ... */ }` output type to a separate `@inline` model if you prefer. 469 470### Procedures 471 472Use `@procedure` for procedures. The first argument must be called `input`: 473 474```typescript 475import "@typelex/emitter"; 476 477namespace com.example.createRecord { 478 @procedure 479 op main(input: { 480 @required text: string; 481 }): { 482 @required uri: atUri; 483 @required cid: cid; 484 }; 485} 486``` 487 488Output: 489 490```json 491// ... 492"main": { 493 "type": "procedure", 494 "input": { 495 "encoding": "application/json", 496 "schema": { 497 "type": "object", 498 "properties": { "text": { "type": "string" } }, 499 "required": ["text"] 500 } 501 }, 502 "output": { 503 "encoding": "application/json", 504 "schema": { 505 "type": "object", 506 "properties": { 507 "uri": { /* ... */ }, 508 "cid": { /* ... */ } 509 }, 510 "required": ["uri", "cid"] 511 } 512 } 513} 514// ... 515``` 516 517Procedures can also receive parameters (rarely used): `op main(input: {}, parameters: {})`. 518 519Use `: void` for no output, or `: never` with `@encoding()` on the `op` for output with encoding but no schema. 520 521### Subscriptions 522 523Use `@subscription` for subscriptions: 524 525```typescript 526import "@typelex/emitter"; 527 528namespace com.atproto.sync.subscribeRepos { 529 @subscription 530 @errors(FutureCursor, ConsumerTooSlow) 531 op main(cursor?: integer): Commit | Sync | unknown; 532 533 model Commit { /* ... */ } 534 model Sync { /* ... */ } 535 model FutureCursor {} 536 model ConsumerTooSlow {} 537} 538``` 539 540Output: 541 542```json 543// ... 544"main": { 545 "type": "subscription", 546 "parameters": { 547 "type": "params", 548 "properties": { "cursor": { /* ... */ } } 549 }, 550 "message": { 551 "schema": { 552 "type": "union", 553 "refs": ["#commit", "#sync"] 554 } 555 }, 556 "errors": [{ "name": "FutureCursor" }, { "name": "ConsumerTooSlow" }] 557} 558// ... 559``` 560 561### Tokens 562 563Use `@token` for empty token models: 564 565```typescript 566namespace com.example.moderation.defs { 567 @token 568 model ReasonSpam {} 569 570 @token 571 model ReasonViolation {} 572 573 model Report { 574 @required reason: (ReasonSpam | ReasonViolation | unknown); 575 } 576} 577``` 578 579Output: 580 581```json 582// ... 583"reasonSpam": { "type": "token" }, 584"reasonViolation": { "type": "token" }, 585"report": { 586 "type": "object", 587 "properties": { 588 "reason": { 589 "type": "union", 590 "refs": ["#reasonSpam", "#reasonViolation"] 591 } 592 }, 593 "required": ["reason"] 594} 595// ... 596``` 597 598## Data Types 599 600All [Lexicon data types](https://atproto.com/specs/lexicon#overview-of-types) are supported. 601 602### Primitive Types 603 604| TypeSpec | Lexicon JSON | 605|----------|--------------| 606| `boolean` | `{"type": "boolean"}` | 607| `integer` | `{"type": "integer"}` | 608| `string` | `{"type": "string"}` | 609| `bytes` | `{"type": "bytes"}` | 610| `cidLink` | `{"type": "cid-link"}` | 611| `unknown` | `{"type": "unknown"}` | 612 613### Format Types 614 615Specialized string formats: 616 617| TypeSpec | Lexicon Format | 618|----------|----------------| 619| `atIdentifier` | `at-identifier` - Handle or DID | 620| `atUri` | `at-uri` - AT Protocol URI | 621| `cid` | `cid` - Content ID | 622| `datetime` | `datetime` - ISO 8601 datetime | 623| `did` | `did` - DID identifier | 624| `handle` | `handle` - Handle identifier | 625| `nsid` | `nsid` - Namespaced ID | 626| `tid` | `tid` - Timestamp ID | 627| `recordKey` | `record-key` - Record key | 628| `uri` | `uri` - Generic URI | 629| `language` | `language` - Language tag | 630 631### Arrays 632 633Use `[]` suffix: 634 635```typescript 636import "@typelex/emitter"; 637 638namespace com.example.arrays { 639 model Main { 640 stringArray?: string[]; 641 642 @minItems(1) 643 @maxItems(10) 644 limitedArray?: integer[]; 645 646 items?: Item[]; 647 mixed?: (TypeA | TypeB | unknown)[]; 648 } 649 // ... 650} 651``` 652 653Output: `{ "type": "array", "items": {...} }`. 654 655Note: `@minItems`/`@maxItems` map to `minLength`/`maxLength` in JSON. 656 657### Blobs 658 659```typescript 660import "@typelex/emitter"; 661 662namespace com.example.blobs { 663 model Main { 664 file?: Blob; 665 image?: Blob<#["image/*"], 5000000>; 666 photo?: Blob<#["image/png", "image/jpeg"], 2000000>; 667 } 668} 669``` 670 671Output: 672 673```json 674// ... 675"image": { 676 "type": "blob", 677 "accept": ["image/*"], 678 "maxSize": 5000000 679} 680// ... 681``` 682 683## Required and Optional Fields 684 685In Lexicon, fields are optional by default. Use `?:`: 686 687```typescript 688import "@typelex/emitter"; 689 690namespace tools.ozone.moderation.defs { 691 model SubjectStatusView { 692 subjectRepoHandle?: string; 693 } 694} 695``` 696 697**Think thrice before adding required fields**—you can't make them optional later. 698 699This is why `@required` is explicit: 700 701```typescript 702import "@typelex/emitter"; 703 704namespace tools.ozone.moderation.defs { 705 model SubjectStatusView { 706 subjectRepoHandle?: string; 707 @required createdAt: datetime; 708 } 709} 710``` 711 712Output: 713 714```json 715// ... 716"required": ["createdAt"] 717// ... 718``` 719 720## Unions 721 722### Open Unions (Recommended) 723 724Unions default to being *open*—allowing you to add more options later. Write `| unknown`: 725 726```typescript 727import "@typelex/emitter"; 728 729namespace app.bsky.feed.post { 730 model Main { 731 embed?: Images | Video | unknown; 732 } 733 734 model Images { /* ... */ } 735 model Video { /* ... */ } 736} 737``` 738 739Output: 740 741```json 742// ... 743"embed": { 744 "type": "union", 745 "refs": ["#images", "#video"] 746} 747// ... 748``` 749 750You can also use the `union` syntax to give it a name: 751 752```typescript 753import "@typelex/emitter"; 754 755namespace app.bsky.feed.post { 756 model Main { 757 embed?: EmbedType; 758 } 759 760 @inline union EmbedType { Images, Video, unknown } 761 762 model Images { /* ... */ } 763 model Video { /* ... */ } 764} 765``` 766 767The `@inline` prevents it from becoming a separate def in the output. 768 769### Known Values (Open Enums) 770 771Suggest common values but allow others with `| string`: 772 773```typescript 774import "@typelex/emitter"; 775 776namespace com.example { 777 model Main { 778 lang?: "en" | "es" | "fr" | string; 779 } 780} 781``` 782 783The `union` syntax works here too: 784 785```typescript 786import "@typelex/emitter"; 787 788namespace com.example { 789 model Main { 790 lang?: Languages; 791 } 792 793 @inline union Languages { "en", "es", "fr", string } 794} 795``` 796 797You can remove `@inline` to make it a reusable `def` accessible from other Lexicons. 798 799### Closed Unions and Enums (Discouraged) 800 801**Heavily discouraged** in Lexicon. 802 803Marking a `union` as `@closed` lets you remove `unknown` from the list of options: 804 805```typescript 806import "@typelex/emitter"; 807 808namespace com.atproto.repo.applyWrites { 809 model Main { 810 @required writes: WriteAction[]; 811 } 812 813 @closed // Discouraged! 814 @inline 815 union WriteAction { Create, Update, Delete } 816 817 model Create { /* ... */ } 818 model Update { /* ... */ } 819 model Delete { /* ... */ } 820} 821``` 822 823Output: 824 825```json 826// ... 827"writes": { 828 "type": "array", 829 "items": { 830 "type": "union", 831 "refs": ["#create", "#update", "#delete"], 832 "closed": true 833 } 834} 835// ... 836``` 837 838With strings or numbers, this becomes a closed `enum`: 839 840```typescript 841import "@typelex/emitter"; 842 843namespace com.atproto.repo.applyWrites { 844 model Main { 845 @required action: WriteAction; 846 } 847 848 @closed // Discouraged! 849 @inline 850 union WriteAction { "create", "update", "delete" } 851} 852``` 853 854Output: 855 856```json 857// ... 858"type": "string", 859"enum": ["create", "update", "delete"] 860// ... 861``` 862 863Avoid closed unions/enums when possible. 864 865## Constraints 866 867### Strings 868 869```typescript 870import "@typelex/emitter"; 871 872namespace com.example { 873 model Main { 874 @minLength(1) 875 @maxLength(100) 876 text?: string; 877 878 @minGraphemes(1) 879 @maxGraphemes(50) 880 displayName?: string; 881 } 882} 883``` 884 885Maps to: `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes` 886 887### Integers 888 889```typescript 890import "@typelex/emitter"; 891 892namespace com.example { 893 model Main { 894 @minValue(1) 895 @maxValue(100) 896 score?: integer; 897 } 898} 899``` 900 901Maps to: `minimum`/`maximum` 902 903### Bytes 904 905```typescript 906import "@typelex/emitter"; 907 908namespace com.example { 909 model Main { 910 @minBytes(1) 911 @maxBytes(1024) 912 data?: bytes; 913 } 914} 915``` 916 917Maps to: `minLength`/`maxLength` 918 919### Arrays 920 921```typescript 922import "@typelex/emitter"; 923 924namespace com.example { 925 model Main { 926 @minItems(1) 927 @maxItems(10) 928 items?: string[]; 929 } 930} 931``` 932 933Maps to: `minLength`/`maxLength` 934 935## Defaults and Constants 936 937### Defaults 938 939```typescript 940import "@typelex/emitter"; 941 942namespace com.example { 943 model Main { 944 version?: integer = 1; 945 lang?: string = "en"; 946 } 947} 948``` 949 950Maps to: `{"default": 1}`, `{"default": "en"}` 951 952### Constants 953 954Use `@readOnly` with a default: 955 956```typescript 957import "@typelex/emitter"; 958 959namespace com.example { 960 model Main { 961 @readOnly status?: string = "active"; 962 } 963} 964``` 965 966Maps to: `{"const": "active"}` 967 968## Nullable Fields 969 970Use `| null` for nullable fields: 971 972```typescript 973import "@typelex/emitter"; 974 975namespace com.example { 976 model Main { 977 @required createdAt: datetime; 978 updatedAt?: datetime | null; // can be omitted or null 979 deletedAt?: datetime; // can only be omitted 980 } 981} 982``` 983 984Output: 985 986```json 987// ... 988"required": ["createdAt"], 989"nullable": ["updatedAt"] 990// ... 991```