An experimental TypeSpec syntax for Lexicon

Add TypeSpec agent skill

+1252
+1252
.agents/skills/typelex.md
··· 1 + # typelex — TypeSpec Emitter for AT Protocol Lexicons 2 + 3 + Typelex 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 + 27 + This 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 + 35 + Typelex is a TypeSpec emitter targeting Lexicon. Here's the same schema in TypeSpec: 36 + 37 + ```typescript 38 + import "@typelex/emitter"; 39 + 40 + namespace app.bsky.bookmark.defs { 41 + model ListItemView { 42 + @required uri: atUri; 43 + } 44 + } 45 + ``` 46 + 47 + Run the compiler, and it generates Lexicon JSON for you. 48 + 49 + The JSON is what you'll publish—typelex just makes authoring easier. Think of it as "CoffeeScript for Lexicon" (however terrible that may be). 50 + 51 + Typelex 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 + 53 + Personally, 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 + 59 + If 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 + 65 + A namespace corresponds to a Lexicon file: 66 + 67 + ```typescript 68 + import "@typelex/emitter"; 69 + 70 + namespace app.bsky.feed.defs { 71 + model PostView { 72 + // ... 73 + } 74 + } 75 + ``` 76 + 77 + This 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 + 89 + If TypeSpec complains about reserved words in namespaces, use backticks: 90 + 91 + ```typescript 92 + import "@typelex/emitter"; 93 + 94 + namespace app.bsky.feed.post.`record` { } 95 + namespace `pub`.blocks.blockquote { } 96 + ``` 97 + 98 + You can define multiple namespaces in one file: 99 + 100 + ```typescript 101 + import "@typelex/emitter"; 102 + 103 + namespace com.example.foo { 104 + model Main { /* ... */ } 105 + } 106 + 107 + namespace com.example.bar { 108 + model Main { /* ... */ } 109 + } 110 + ``` 111 + 112 + This emits two files: `com/example/foo.json` and `com/example/bar.json`. 113 + 114 + You can `import` other `.tsp` files to reuse definitions. Output structure is determined solely by namespaces, not by source file organization. 115 + 116 + ## Models 117 + 118 + By default, **every `model` becomes a Lexicon definition**: 119 + 120 + ```typescript 121 + import "@typelex/emitter"; 122 + 123 + namespace app.bsky.feed.defs { 124 + model PostView { /* ... */ } 125 + model ViewerState { /* ... */ } 126 + } 127 + ``` 128 + 129 + Model 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 + 142 + Models 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 + 146 + By convention, a namespace must either end with `.defs` or have a `Main` model. 147 + 148 + Use `.defs` for a grabbag of reusable definitions: 149 + 150 + ```typescript 151 + import "@typelex/emitter"; 152 + 153 + namespace app.bsky.feed.defs { 154 + model PostView { /* ... */ } 155 + model ViewerState { /* ... */ } 156 + } 157 + ``` 158 + 159 + For a Lexicon about one main concept, add a `Main` model instead: 160 + 161 + ```typescript 162 + import "@typelex/emitter"; 163 + 164 + namespace app.bsky.embed.video { 165 + model Main { /* ... */ } 166 + model Caption { /* ... */ } 167 + } 168 + ``` 169 + 170 + Pick one or the other—the compiler will error if you don't. 171 + 172 + ### References 173 + 174 + Models can reference other models: 175 + 176 + ```typescript 177 + import "@typelex/emitter"; 178 + 179 + namespace app.bsky.embed.video { 180 + model Main { 181 + captions?: Caption[]; 182 + } 183 + model Caption { /* ... */ } 184 + } 185 + ``` 186 + 187 + This 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 + 208 + You can also reference models from other namespaces: 209 + 210 + ```typescript 211 + import "@typelex/emitter"; 212 + 213 + namespace app.bsky.actor.profile { 214 + model Main { 215 + labels?: (com.atproto.label.defs.SelfLabels | unknown); 216 + } 217 + } 218 + 219 + namespace com.atproto.label.defs { 220 + model SelfLabels { /* ... */ } 221 + } 222 + ``` 223 + 224 + This 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 + 237 + This works across files too—just remember to `import` the file with the definition. 238 + 239 + ### External Stubs 240 + 241 + If you don't have TypeSpec definitions for external Lexicons, you can stub them out using the `@external` decorator: 242 + 243 + ```typescript 244 + import "@typelex/emitter"; 245 + 246 + namespace 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 254 + namespace com.atproto.label.defs { 255 + model SelfLabels { } 256 + } 257 + ``` 258 + 259 + The `@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 + 261 + Starting with 0.3.0, typelex will automatically generate a `typelex/externals.tsp` file based on the JSON files in your `lexicons/` folder, and enforce that it's imported into your `typelex/main.tsp` entry point. However, this will *not* include Lexicons from your app's namespace, but only external ones. 262 + 263 + You'll want to ensure the real JSON for external Lexicons is available before running codegen. 264 + 265 + ### Inline Models 266 + 267 + By default, every `model` becomes a top-level def: 268 + 269 + ```typescript 270 + import "@typelex/emitter"; 271 + 272 + namespace app.bsky.embed.video { 273 + model Main { 274 + captions?: Caption[]; 275 + } 276 + model Caption { /* ... */ } 277 + } 278 + ``` 279 + 280 + This creates two defs: `main` and `caption`. 281 + 282 + Use `@inline` to expand a model inline instead: 283 + 284 + ```typescript 285 + import "@typelex/emitter"; 286 + 287 + namespace app.bsky.embed.video { 288 + model Main { 289 + captions?: Caption[]; 290 + } 291 + 292 + @inline 293 + model Caption { 294 + text?: string 295 + } 296 + } 297 + ``` 298 + 299 + Now `Caption` is expanded inline: 300 + 301 + ```json 302 + // ... 303 + "captions": { 304 + "type": "array", 305 + "items": { 306 + "type": "object", 307 + "properties": { "text": { "type": "string" } } 308 + } 309 + } 310 + // ... 311 + ``` 312 + 313 + Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. 314 + 315 + ### Scalars 316 + 317 + TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models): 318 + 319 + ```typescript 320 + import "@typelex/emitter"; 321 + 322 + namespace com.example { 323 + model Main { 324 + handle?: Handle; 325 + bio?: Bio; 326 + } 327 + 328 + @maxLength(50) 329 + scalar Handle extends string; 330 + 331 + @maxLength(256) 332 + @maxGraphemes(128) 333 + scalar Bio extends string; 334 + } 335 + ``` 336 + 337 + This creates three defs: `main`, `handle`, and `bio`: 338 + 339 + ```json 340 + { 341 + "id": "com.example", 342 + "defs": { 343 + "main": { 344 + "type": "object", 345 + "properties": { 346 + "handle": { "type": "ref", "ref": "#handle" }, 347 + "bio": { "type": "ref", "ref": "#bio" } 348 + } 349 + }, 350 + "handle": { 351 + "type": "string", 352 + "maxLength": 50 353 + }, 354 + "bio": { 355 + "type": "string", 356 + "maxLength": 256, 357 + "maxGraphemes": 128 358 + } 359 + } 360 + } 361 + ``` 362 + 363 + Use `@inline` to expand a scalar inline instead: 364 + 365 + ```typescript 366 + import "@typelex/emitter"; 367 + 368 + namespace com.example { 369 + model Main { 370 + handle?: Handle; 371 + } 372 + 373 + @inline 374 + @maxLength(50) 375 + scalar Handle extends string; 376 + } 377 + ``` 378 + 379 + Now `Handle` is expanded inline (no separate def): 380 + 381 + ```json 382 + // ... 383 + "properties": { 384 + "handle": { "type": "string", "maxLength": 50 } 385 + } 386 + // ... 387 + ``` 388 + 389 + ## Top-Level Lexicon Types 390 + 391 + TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. 392 + 393 + ### Objects 394 + 395 + A plain `model` becomes a Lexicon object: 396 + 397 + ```typescript 398 + import "@typelex/emitter"; 399 + 400 + namespace com.example.post { 401 + model Main { /* ... */ } 402 + } 403 + ``` 404 + 405 + Output: 406 + 407 + ```json 408 + // ... 409 + "main": { 410 + "type": "object", 411 + "properties": { /* ... */ } 412 + } 413 + // ... 414 + ``` 415 + 416 + ### Records 417 + 418 + Use `@rec` to make a model a Lexicon record: 419 + 420 + ```typescript 421 + import "@typelex/emitter"; 422 + 423 + namespace com.example.post { 424 + @rec("tid") 425 + model Main { /* ... */ } 426 + } 427 + ``` 428 + 429 + Output: 430 + 431 + ```json 432 + // ... 433 + "main": { 434 + "type": "record", 435 + "key": "tid", 436 + "record": { "type": "object", "properties": { /* ... */ } } 437 + } 438 + // ... 439 + ``` 440 + 441 + You can pass any [Record Key Type](https://atproto.com/specs/record-key): `@rec("tid")`, `@rec("nsid")`, `@rec("literal:self")`, etc. 442 + 443 + (It's `@rec` not `@record` because "record" is reserved in TypeSpec.) 444 + 445 + ### Queries 446 + 447 + In TypeSpec, use [`op`](https://typespec.io/docs/language-basics/operations/) for functions. Mark with `@query` for queries: 448 + 449 + ```typescript 450 + import "@typelex/emitter"; 451 + 452 + namespace com.atproto.repo.getRecord { 453 + @query 454 + op main( 455 + @required repo: atIdentifier, 456 + @required collection: nsid, 457 + @required rkey: recordKey, 458 + cid?: cid 459 + ): { 460 + @required uri: atUri; 461 + cid?: cid; 462 + }; 463 + } 464 + ``` 465 + 466 + Arguments become `parameters`, return type becomes `output`: 467 + 468 + ```json 469 + // ... 470 + "main": { 471 + "type": "query", 472 + "parameters": { 473 + "type": "params", 474 + "properties": { 475 + "repo": { /* ... */ }, 476 + "collection": { /* ... */ }, 477 + // ... 478 + }, 479 + "required": ["repo", "collection", "rkey"] 480 + }, 481 + "output": { 482 + "encoding": "application/json", 483 + "schema": { 484 + "type": "object", 485 + "properties": { 486 + "uri": { /* ... */ }, 487 + "cid": { /* ... */ } 488 + }, 489 + "required": ["uri"] 490 + } 491 + } 492 + } 493 + // ... 494 + ``` 495 + 496 + `encoding` defaults to `"application/json"`. Override with `@encoding("foo/bar")` on the `op`. 497 + 498 + Declare errors with `@errors`: 499 + 500 + ```typescript 501 + import "@typelex/emitter"; 502 + 503 + namespace com.atproto.repo.getRecord { 504 + @query 505 + @errors(FooError, BarError) 506 + op main(/* ... */): { /* ... */ }; 507 + 508 + model FooError {} 509 + model BarError {} 510 + } 511 + ``` 512 + 513 + You can extract the `{ /* ... */ }` output type to a separate `@inline` model if you prefer. 514 + 515 + ### Procedures 516 + 517 + Use `@procedure` for procedures. The first argument must be called `input`: 518 + 519 + ```typescript 520 + import "@typelex/emitter"; 521 + 522 + namespace com.example.createRecord { 523 + @procedure 524 + op main(input: { 525 + @required text: string; 526 + }): { 527 + @required uri: atUri; 528 + @required cid: cid; 529 + }; 530 + } 531 + ``` 532 + 533 + Output: 534 + 535 + ```json 536 + // ... 537 + "main": { 538 + "type": "procedure", 539 + "input": { 540 + "encoding": "application/json", 541 + "schema": { 542 + "type": "object", 543 + "properties": { "text": { "type": "string" } }, 544 + "required": ["text"] 545 + } 546 + }, 547 + "output": { 548 + "encoding": "application/json", 549 + "schema": { 550 + "type": "object", 551 + "properties": { 552 + "uri": { /* ... */ }, 553 + "cid": { /* ... */ } 554 + }, 555 + "required": ["uri", "cid"] 556 + } 557 + } 558 + } 559 + // ... 560 + ``` 561 + 562 + Procedures can also receive parameters (rarely used): `op main(input: {}, parameters: {})`. 563 + 564 + Use `: void` for no output, or `: never` with `@encoding()` on the `op` for output with encoding but no schema. 565 + 566 + ### Subscriptions 567 + 568 + Use `@subscription` for subscriptions: 569 + 570 + ```typescript 571 + import "@typelex/emitter"; 572 + 573 + namespace com.atproto.sync.subscribeRepos { 574 + @subscription 575 + @errors(FutureCursor, ConsumerTooSlow) 576 + op main(cursor?: integer): Commit | Sync | unknown; 577 + 578 + model Commit { /* ... */ } 579 + model Sync { /* ... */ } 580 + model FutureCursor {} 581 + model ConsumerTooSlow {} 582 + } 583 + ``` 584 + 585 + Output: 586 + 587 + ```json 588 + // ... 589 + "main": { 590 + "type": "subscription", 591 + "parameters": { 592 + "type": "params", 593 + "properties": { "cursor": { /* ... */ } } 594 + }, 595 + "message": { 596 + "schema": { 597 + "type": "union", 598 + "refs": ["#commit", "#sync"] 599 + } 600 + }, 601 + "errors": [{ "name": "FutureCursor" }, { "name": "ConsumerTooSlow" }] 602 + } 603 + // ... 604 + ``` 605 + 606 + ### Tokens 607 + 608 + Use `@token` for empty token models: 609 + 610 + ```typescript 611 + namespace com.example.moderation.defs { 612 + @token 613 + model ReasonSpam {} 614 + 615 + @token 616 + model ReasonViolation {} 617 + 618 + model Report { 619 + @required reason: (ReasonSpam | ReasonViolation | unknown); 620 + } 621 + } 622 + ``` 623 + 624 + Output: 625 + 626 + ```json 627 + // ... 628 + "reasonSpam": { "type": "token" }, 629 + "reasonViolation": { "type": "token" }, 630 + "report": { 631 + "type": "object", 632 + "properties": { 633 + "reason": { 634 + "type": "union", 635 + "refs": ["#reasonSpam", "#reasonViolation"] 636 + } 637 + }, 638 + "required": ["reason"] 639 + } 640 + // ... 641 + ``` 642 + 643 + ## Data Types 644 + 645 + All [Lexicon data types](https://atproto.com/specs/lexicon#overview-of-types) are supported. 646 + 647 + ### Primitive Types 648 + 649 + | TypeSpec | Lexicon JSON | 650 + |----------|--------------| 651 + | `boolean` | `{"type": "boolean"}` | 652 + | `integer` | `{"type": "integer"}` | 653 + | `string` | `{"type": "string"}` | 654 + | `bytes` | `{"type": "bytes"}` | 655 + | `cidLink` | `{"type": "cid-link"}` | 656 + | `unknown` | `{"type": "unknown"}` | 657 + 658 + ### Format Types 659 + 660 + Specialized string formats: 661 + 662 + | TypeSpec | Lexicon Format | 663 + |----------|----------------| 664 + | `atIdentifier` | `at-identifier` - Handle or DID | 665 + | `atUri` | `at-uri` - AT Protocol URI | 666 + | `cid` | `cid` - Content ID | 667 + | `datetime` | `datetime` - ISO 8601 datetime | 668 + | `did` | `did` - DID identifier | 669 + | `handle` | `handle` - Handle identifier | 670 + | `nsid` | `nsid` - Namespaced ID | 671 + | `tid` | `tid` - Timestamp ID | 672 + | `recordKey` | `record-key` - Record key | 673 + | `uri` | `uri` - Generic URI | 674 + | `language` | `language` - Language tag | 675 + 676 + ### Arrays 677 + 678 + Use `[]` suffix: 679 + 680 + ```typescript 681 + import "@typelex/emitter"; 682 + 683 + namespace com.example.arrays { 684 + model Main { 685 + stringArray?: string[]; 686 + 687 + @minItems(1) 688 + @maxItems(10) 689 + limitedArray?: integer[]; 690 + 691 + items?: Item[]; 692 + mixed?: (TypeA | TypeB | unknown)[]; 693 + } 694 + // ... 695 + } 696 + ``` 697 + 698 + Output: `{ "type": "array", "items": {...} }`. 699 + 700 + Note: `@minItems`/`@maxItems` map to `minLength`/`maxLength` in JSON. 701 + 702 + ### Blobs 703 + 704 + ```typescript 705 + import "@typelex/emitter"; 706 + 707 + namespace com.example.blobs { 708 + model Main { 709 + file?: Blob; 710 + image?: Blob<#["image/*"], 5000000>; 711 + photo?: Blob<#["image/png", "image/jpeg"], 2000000>; 712 + } 713 + } 714 + ``` 715 + 716 + Output: 717 + 718 + ```json 719 + // ... 720 + "image": { 721 + "type": "blob", 722 + "accept": ["image/*"], 723 + "maxSize": 5000000 724 + } 725 + // ... 726 + ``` 727 + 728 + ## Required and Optional Fields 729 + 730 + In Lexicon, fields are optional by default. Use `?:`: 731 + 732 + ```typescript 733 + import "@typelex/emitter"; 734 + 735 + namespace tools.ozone.moderation.defs { 736 + model SubjectStatusView { 737 + subjectRepoHandle?: string; 738 + } 739 + } 740 + ``` 741 + 742 + **Think thrice before adding required fields**—you can't make them optional later. 743 + 744 + This is why `@required` is explicit: 745 + 746 + ```typescript 747 + import "@typelex/emitter"; 748 + 749 + namespace tools.ozone.moderation.defs { 750 + model SubjectStatusView { 751 + subjectRepoHandle?: string; 752 + @required createdAt: datetime; 753 + } 754 + } 755 + ``` 756 + 757 + Output: 758 + 759 + ```json 760 + // ... 761 + "required": ["createdAt"] 762 + // ... 763 + ``` 764 + 765 + ## Unions 766 + 767 + ### Open Unions (Recommended) 768 + 769 + Unions default to being *open*—allowing you to add more options later. Write `| unknown`: 770 + 771 + ```typescript 772 + import "@typelex/emitter"; 773 + 774 + namespace app.bsky.feed.post { 775 + model Main { 776 + embed?: Images | Video | unknown; 777 + } 778 + 779 + model Images { /* ... */ } 780 + model Video { /* ... */ } 781 + } 782 + ``` 783 + 784 + Output: 785 + 786 + ```json 787 + // ... 788 + "embed": { 789 + "type": "union", 790 + "refs": ["#images", "#video"] 791 + } 792 + // ... 793 + ``` 794 + 795 + You can also use the `union` syntax to give it a name: 796 + 797 + ```typescript 798 + import "@typelex/emitter"; 799 + 800 + namespace app.bsky.feed.post { 801 + model Main { 802 + embed?: EmbedType; 803 + } 804 + 805 + @inline union EmbedType { Images, Video, unknown } 806 + 807 + model Images { /* ... */ } 808 + model Video { /* ... */ } 809 + } 810 + ``` 811 + 812 + The `@inline` prevents it from becoming a separate def in the output. 813 + 814 + ### Known Values (Open Enums) 815 + 816 + Suggest common values but allow others with `| string`: 817 + 818 + ```typescript 819 + import "@typelex/emitter"; 820 + 821 + namespace com.example { 822 + model Main { 823 + lang?: "en" | "es" | "fr" | string; 824 + } 825 + } 826 + ``` 827 + 828 + The `union` syntax works here too: 829 + 830 + ```typescript 831 + import "@typelex/emitter"; 832 + 833 + namespace com.example { 834 + model Main { 835 + lang?: Languages; 836 + } 837 + 838 + @inline union Languages { "en", "es", "fr", string } 839 + } 840 + ``` 841 + 842 + You can remove `@inline` to make it a reusable `def` accessible from other Lexicons. 843 + 844 + ### Closed Unions and Enums (Discouraged) 845 + 846 + **Heavily discouraged** in Lexicon. 847 + 848 + Marking a `union` as `@closed` lets you remove `unknown` from the list of options: 849 + 850 + ```typescript 851 + import "@typelex/emitter"; 852 + 853 + namespace com.atproto.repo.applyWrites { 854 + model Main { 855 + @required writes: WriteAction[]; 856 + } 857 + 858 + @closed // Discouraged! 859 + @inline 860 + union WriteAction { Create, Update, Delete } 861 + 862 + model Create { /* ... */ } 863 + model Update { /* ... */ } 864 + model Delete { /* ... */ } 865 + } 866 + ``` 867 + 868 + Output: 869 + 870 + ```json 871 + // ... 872 + "writes": { 873 + "type": "array", 874 + "items": { 875 + "type": "union", 876 + "refs": ["#create", "#update", "#delete"], 877 + "closed": true 878 + } 879 + } 880 + // ... 881 + ``` 882 + 883 + With strings or numbers, this becomes a closed `enum`: 884 + 885 + ```typescript 886 + import "@typelex/emitter"; 887 + 888 + namespace com.atproto.repo.applyWrites { 889 + model Main { 890 + @required action: WriteAction; 891 + } 892 + 893 + @closed // Discouraged! 894 + @inline 895 + union WriteAction { "create", "update", "delete" } 896 + } 897 + ``` 898 + 899 + Output: 900 + 901 + ```json 902 + // ... 903 + "type": "string", 904 + "enum": ["create", "update", "delete"] 905 + // ... 906 + ``` 907 + 908 + Avoid closed unions/enums when possible. 909 + 910 + ## Constraints 911 + 912 + ### Strings 913 + 914 + ```typescript 915 + import "@typelex/emitter"; 916 + 917 + namespace com.example { 918 + model Main { 919 + @minLength(1) 920 + @maxLength(100) 921 + text?: string; 922 + 923 + @minGraphemes(1) 924 + @maxGraphemes(50) 925 + displayName?: string; 926 + } 927 + } 928 + ``` 929 + 930 + Maps to: `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes` 931 + 932 + ### Integers 933 + 934 + ```typescript 935 + import "@typelex/emitter"; 936 + 937 + namespace com.example { 938 + model Main { 939 + @minValue(1) 940 + @maxValue(100) 941 + score?: integer; 942 + } 943 + } 944 + ``` 945 + 946 + Maps to: `minimum`/`maximum` 947 + 948 + ### Bytes 949 + 950 + ```typescript 951 + import "@typelex/emitter"; 952 + 953 + namespace com.example { 954 + model Main { 955 + @minBytes(1) 956 + @maxBytes(1024) 957 + data?: bytes; 958 + } 959 + } 960 + ``` 961 + 962 + Maps to: `minLength`/`maxLength` 963 + 964 + ### Arrays 965 + 966 + ```typescript 967 + import "@typelex/emitter"; 968 + 969 + namespace com.example { 970 + model Main { 971 + @minItems(1) 972 + @maxItems(10) 973 + items?: string[]; 974 + } 975 + } 976 + ``` 977 + 978 + Maps to: `minLength`/`maxLength` 979 + 980 + ## Defaults and Constants 981 + 982 + ### Property Defaults 983 + 984 + You can set default values on properties: 985 + 986 + ```typescript 987 + import "@typelex/emitter"; 988 + 989 + namespace com.example { 990 + model Main { 991 + version?: integer = 1; 992 + lang?: string = "en"; 993 + } 994 + } 995 + ``` 996 + 997 + Maps to: `{"default": 1}`, `{"default": "en"}` 998 + 999 + ### Type Defaults 1000 + 1001 + You can also set defaults on scalar and union types using the `@default` decorator: 1002 + 1003 + ```typescript 1004 + import "@typelex/emitter"; 1005 + 1006 + namespace com.example { 1007 + model Main { 1008 + mode?: Mode; 1009 + priority?: Priority; 1010 + } 1011 + 1012 + @default("standard") 1013 + scalar Mode extends string; 1014 + 1015 + @default(1) 1016 + @closed 1017 + @inline 1018 + union Priority { 1, 2, 3 } 1019 + } 1020 + ``` 1021 + 1022 + This creates a default on the type definition itself: 1023 + 1024 + ```json 1025 + { 1026 + "defs": { 1027 + "mode": { 1028 + "type": "string", 1029 + "default": "standard" 1030 + } 1031 + } 1032 + } 1033 + ``` 1034 + 1035 + For unions with token references, pass the model directly: 1036 + 1037 + ```typescript 1038 + import "@typelex/emitter"; 1039 + 1040 + namespace com.example { 1041 + model Main { 1042 + eventType?: EventType; 1043 + } 1044 + 1045 + @default(InPerson) 1046 + union EventType { Hybrid, InPerson, Virtual, string } 1047 + 1048 + @token model Hybrid {} 1049 + @token model InPerson {} 1050 + @token model Virtual {} 1051 + } 1052 + ``` 1053 + 1054 + This resolves to the fully-qualified token NSID: 1055 + 1056 + ```json 1057 + { 1058 + "eventType": { 1059 + "type": "string", 1060 + "knownValues": [ 1061 + "com.example#hybrid", 1062 + "com.example#inPerson", 1063 + "com.example#virtual" 1064 + ], 1065 + "default": "com.example#inPerson" 1066 + } 1067 + } 1068 + ``` 1069 + 1070 + **Important:** When a scalar or union creates a standalone def (not `@inline`), property-level defaults must match the type's `@default`. Otherwise you'll get an error: 1071 + 1072 + ```typescript 1073 + @default("standard") 1074 + scalar Mode extends string; 1075 + 1076 + model Main { 1077 + mode?: Mode = "custom"; // ERROR: Conflicting defaults! 1078 + } 1079 + ``` 1080 + 1081 + Solutions: 1082 + 1. Make the defaults match: `mode?: Mode = "standard"` 1083 + 2. Mark the type `@inline`: Allows property-level defaults 1084 + 3. Remove the property default: Uses the type's default 1085 + 1086 + ### Constants 1087 + 1088 + Use `@readOnly` with a default: 1089 + 1090 + ```typescript 1091 + import "@typelex/emitter"; 1092 + 1093 + namespace com.example { 1094 + model Main { 1095 + @readOnly status?: string = "active"; 1096 + } 1097 + } 1098 + ``` 1099 + 1100 + Maps to: `{"const": "active"}` 1101 + 1102 + ## Nullable Fields 1103 + 1104 + Use `| null` for nullable fields: 1105 + 1106 + ```typescript 1107 + import "@typelex/emitter"; 1108 + 1109 + namespace com.example { 1110 + model Main { 1111 + @required createdAt: datetime; 1112 + updatedAt?: datetime | null; // can be omitted or null 1113 + deletedAt?: datetime; // can only be omitted 1114 + } 1115 + } 1116 + ``` 1117 + 1118 + Output: 1119 + 1120 + ```json 1121 + // ... 1122 + "required": ["createdAt"], 1123 + "nullable": ["updatedAt"] 1124 + // ... 1125 + ``` 1126 + 1127 + ## CLI 1128 + 1129 + The `@typelex/cli` package provides the `typelex` command for initializing projects and compiling TypeSpec to Lexicon JSON. 1130 + 1131 + ### Installation 1132 + 1133 + ```bash 1134 + # npm 1135 + npm install --save-dev @typelex/cli @typelex/emitter 1136 + 1137 + # pnpm 1138 + pnpm add -D @typelex/cli @typelex/emitter 1139 + 1140 + # bun 1141 + bun add -D @typelex/cli @typelex/emitter 1142 + ``` 1143 + 1144 + ### `typelex init` 1145 + 1146 + Initializes a new typelex project in an existing package: 1147 + 1148 + ```bash 1149 + typelex init 1150 + ``` 1151 + 1152 + This runs a two-phase process: 1153 + 1154 + 1. **Installs dependencies** — auto-detects your package manager (npm/pnpm/yarn) and installs `@typelex/cli` and `@typelex/emitter` as dev dependencies. Any extra flags are passed through to the package manager (e.g., `--workspace-root` for pnpm). 1155 + 2. **Interactive setup** — prompts for your namespace pattern (e.g., `com.example.*`), then: 1156 + - Auto-detects your `lexicons/` directory 1157 + - Creates `typelex/main.tsp` with a starter template 1158 + - Creates `typelex/externals.tsp` from any existing JSON lexicons in `lexicons/` 1159 + - Adds a `"build:typelex"` script to your `package.json` 1160 + 1161 + The generated `typelex/main.tsp` looks like this (for namespace `com.example.*`): 1162 + 1163 + ```typescript 1164 + import "@typelex/emitter"; 1165 + import "./externals.tsp"; 1166 + 1167 + namespace com.example.example.profile { 1168 + /** My profile. */ 1169 + @rec("literal:self") 1170 + model Main { 1171 + /** Free-form profile description.*/ 1172 + @maxGraphemes(256) 1173 + description?: string; 1174 + } 1175 + } 1176 + ``` 1177 + 1178 + ### `typelex compile <namespace>` 1179 + 1180 + Compiles TypeSpec files into Lexicon JSON: 1181 + 1182 + ```bash 1183 + typelex compile com.example.* 1184 + ``` 1185 + 1186 + The namespace argument is required and must end with `.*`. It tells the compiler which lexicons belong to your project (everything else in `lexicons/` is treated as external). 1187 + 1188 + **Flags:** 1189 + 1190 + | Flag | Default | Description | 1191 + |------|---------|-------------| 1192 + | `--out <dir>` | `./lexicons` | Output directory (must end with `lexicons`) | 1193 + | `--watch` | `false` | Watch mode for continuous recompilation | 1194 + 1195 + **What happens on compile:** 1196 + 1197 + 1. **Auto-generates `typelex/externals.tsp`** — scans the output directory for JSON lexicons whose NSID doesn't match your namespace prefix, and generates `@external` namespace stubs with empty models for each. 1198 + 2. **Validates imports** — enforces that `typelex/main.tsp` starts with exactly: 1199 + ``` 1200 + import "@typelex/emitter"; 1201 + import "./externals.tsp"; 1202 + ``` 1203 + The build fails with a clear error if either line is wrong or missing. 1204 + 3. **Runs the TypeSpec compiler** — spawns `tsp compile` with the emitter configured to output to your lexicons directory. 1205 + 1206 + ### Project Structure 1207 + 1208 + A typical typelex project looks like: 1209 + 1210 + ``` 1211 + my-project/ 1212 + ├── package.json 1213 + ├── lexicons/ # JSON output (and external lexicons) 1214 + │ ├── com/ 1215 + │ │ └── example/ 1216 + │ │ └── post.json # ← generated by typelex 1217 + │ └── app/ 1218 + │ └── bsky/ # ← external lexicons 1219 + └── typelex/ # TypeSpec source 1220 + ├── main.tsp # Entry point (you write this) 1221 + └── externals.tsp # Auto-generated stubs (don't edit) 1222 + ``` 1223 + 1224 + ### Typical Workflow 1225 + 1226 + Add scripts to your `package.json` (or let `typelex init` do it): 1227 + 1228 + ```json 1229 + { 1230 + "scripts": { 1231 + "build:typelex": "typelex compile com.example.*", 1232 + "build": "pnpm run build:typelex && pnpm run build:codegen" 1233 + } 1234 + } 1235 + ``` 1236 + 1237 + Then: 1238 + 1239 + ```bash 1240 + # One-off compile 1241 + typelex compile com.example.* 1242 + 1243 + # Watch mode during development 1244 + typelex compile com.example.* --watch 1245 + 1246 + # Custom output directory 1247 + typelex compile com.example.* --out ../../lexicons 1248 + ``` 1249 + 1250 + ### Keyword Escaping 1251 + 1252 + The CLI automatically escapes TypeSpec reserved keywords in namespace segments with backticks. For example, if your lexicons use `pub` or `record` as namespace segments, the generated externals will use `` `pub` `` and `` `record` `` so TypeSpec can parse them.