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 261Starting 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 263You'll want to ensure the real JSON for external Lexicons is available before running codegen. 264 265### Inline Models 266 267By default, every `model` becomes a top-level def: 268 269```typescript 270import "@typelex/emitter"; 271 272namespace app.bsky.embed.video { 273 model Main { 274 captions?: Caption[]; 275 } 276 model Caption { /* ... */ } 277} 278``` 279 280This creates two defs: `main` and `caption`. 281 282Use `@inline` to expand a model inline instead: 283 284```typescript 285import "@typelex/emitter"; 286 287namespace app.bsky.embed.video { 288 model Main { 289 captions?: Caption[]; 290 } 291 292 @inline 293 model Caption { 294 text?: string 295 } 296} 297``` 298 299Now `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 313Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. 314 315### Scalars 316 317TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models): 318 319```typescript 320import "@typelex/emitter"; 321 322namespace 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 337This 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 363Use `@inline` to expand a scalar inline instead: 364 365```typescript 366import "@typelex/emitter"; 367 368namespace com.example { 369 model Main { 370 handle?: Handle; 371 } 372 373 @inline 374 @maxLength(50) 375 scalar Handle extends string; 376} 377``` 378 379Now `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 391TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. 392 393### Objects 394 395A plain `model` becomes a Lexicon object: 396 397```typescript 398import "@typelex/emitter"; 399 400namespace com.example.post { 401 model Main { /* ... */ } 402} 403``` 404 405Output: 406 407```json 408// ... 409"main": { 410 "type": "object", 411 "properties": { /* ... */ } 412} 413// ... 414``` 415 416### Records 417 418Use `@rec` to make a model a Lexicon record: 419 420```typescript 421import "@typelex/emitter"; 422 423namespace com.example.post { 424 @rec("tid") 425 model Main { /* ... */ } 426} 427``` 428 429Output: 430 431```json 432// ... 433"main": { 434 "type": "record", 435 "key": "tid", 436 "record": { "type": "object", "properties": { /* ... */ } } 437} 438// ... 439``` 440 441You 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 447In TypeSpec, use [`op`](https://typespec.io/docs/language-basics/operations/) for functions. Mark with `@query` for queries: 448 449```typescript 450import "@typelex/emitter"; 451 452namespace 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 466Arguments 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 498Declare errors with `@errors`: 499 500```typescript 501import "@typelex/emitter"; 502 503namespace com.atproto.repo.getRecord { 504 @query 505 @errors(FooError, BarError) 506 op main(/* ... */): { /* ... */ }; 507 508 model FooError {} 509 model BarError {} 510} 511``` 512 513You can extract the `{ /* ... */ }` output type to a separate `@inline` model if you prefer. 514 515### Procedures 516 517Use `@procedure` for procedures. The first argument must be called `input`: 518 519```typescript 520import "@typelex/emitter"; 521 522namespace 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 533Output: 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 562Procedures can also receive parameters (rarely used): `op main(input: {}, parameters: {})`. 563 564Use `: void` for no output, or `: never` with `@encoding()` on the `op` for output with encoding but no schema. 565 566### Subscriptions 567 568Use `@subscription` for subscriptions: 569 570```typescript 571import "@typelex/emitter"; 572 573namespace 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 585Output: 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 608Use `@token` for empty token models: 609 610```typescript 611namespace 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 624Output: 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 645All [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 660Specialized 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 678Use `[]` suffix: 679 680```typescript 681import "@typelex/emitter"; 682 683namespace 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 698Output: `{ "type": "array", "items": {...} }`. 699 700Note: `@minItems`/`@maxItems` map to `minLength`/`maxLength` in JSON. 701 702### Blobs 703 704```typescript 705import "@typelex/emitter"; 706 707namespace 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 716Output: 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 730In Lexicon, fields are optional by default. Use `?:`: 731 732```typescript 733import "@typelex/emitter"; 734 735namespace 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 744This is why `@required` is explicit: 745 746```typescript 747import "@typelex/emitter"; 748 749namespace tools.ozone.moderation.defs { 750 model SubjectStatusView { 751 subjectRepoHandle?: string; 752 @required createdAt: datetime; 753 } 754} 755``` 756 757Output: 758 759```json 760// ... 761"required": ["createdAt"] 762// ... 763``` 764 765## Unions 766 767### Open Unions (Recommended) 768 769Unions default to being *open*—allowing you to add more options later. Write `| unknown`: 770 771```typescript 772import "@typelex/emitter"; 773 774namespace app.bsky.feed.post { 775 model Main { 776 embed?: Images | Video | unknown; 777 } 778 779 model Images { /* ... */ } 780 model Video { /* ... */ } 781} 782``` 783 784Output: 785 786```json 787// ... 788"embed": { 789 "type": "union", 790 "refs": ["#images", "#video"] 791} 792// ... 793``` 794 795You can also use the `union` syntax to give it a name: 796 797```typescript 798import "@typelex/emitter"; 799 800namespace 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 812The `@inline` prevents it from becoming a separate def in the output. 813 814### Known Values (Open Enums) 815 816Suggest common values but allow others with `| string`: 817 818```typescript 819import "@typelex/emitter"; 820 821namespace com.example { 822 model Main { 823 lang?: "en" | "es" | "fr" | string; 824 } 825} 826``` 827 828The `union` syntax works here too: 829 830```typescript 831import "@typelex/emitter"; 832 833namespace com.example { 834 model Main { 835 lang?: Languages; 836 } 837 838 @inline union Languages { "en", "es", "fr", string } 839} 840``` 841 842You 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 848Marking a `union` as `@closed` lets you remove `unknown` from the list of options: 849 850```typescript 851import "@typelex/emitter"; 852 853namespace 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 868Output: 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 883With strings or numbers, this becomes a closed `enum`: 884 885```typescript 886import "@typelex/emitter"; 887 888namespace 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 899Output: 900 901```json 902// ... 903"type": "string", 904"enum": ["create", "update", "delete"] 905// ... 906``` 907 908Avoid closed unions/enums when possible. 909 910## Constraints 911 912### Strings 913 914```typescript 915import "@typelex/emitter"; 916 917namespace 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 930Maps to: `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes` 931 932### Integers 933 934```typescript 935import "@typelex/emitter"; 936 937namespace com.example { 938 model Main { 939 @minValue(1) 940 @maxValue(100) 941 score?: integer; 942 } 943} 944``` 945 946Maps to: `minimum`/`maximum` 947 948### Bytes 949 950```typescript 951import "@typelex/emitter"; 952 953namespace com.example { 954 model Main { 955 @minBytes(1) 956 @maxBytes(1024) 957 data?: bytes; 958 } 959} 960``` 961 962Maps to: `minLength`/`maxLength` 963 964### Arrays 965 966```typescript 967import "@typelex/emitter"; 968 969namespace com.example { 970 model Main { 971 @minItems(1) 972 @maxItems(10) 973 items?: string[]; 974 } 975} 976``` 977 978Maps to: `minLength`/`maxLength` 979 980## Defaults and Constants 981 982### Property Defaults 983 984You can set default values on properties: 985 986```typescript 987import "@typelex/emitter"; 988 989namespace com.example { 990 model Main { 991 version?: integer = 1; 992 lang?: string = "en"; 993 } 994} 995``` 996 997Maps to: `{"default": 1}`, `{"default": "en"}` 998 999### Type Defaults 1000 1001You can also set defaults on scalar and union types using the `@default` decorator: 1002 1003```typescript 1004import "@typelex/emitter"; 1005 1006namespace 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 1022This 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 1035For unions with token references, pass the model directly: 1036 1037```typescript 1038import "@typelex/emitter"; 1039 1040namespace 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 1054This 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") 1074scalar Mode extends string; 1075 1076model Main { 1077 mode?: Mode = "custom"; // ERROR: Conflicting defaults! 1078} 1079``` 1080 1081Solutions: 10821. Make the defaults match: `mode?: Mode = "standard"` 10832. Mark the type `@inline`: Allows property-level defaults 10843. Remove the property default: Uses the type's default 1085 1086### Constants 1087 1088Use `@readOnly` with a default: 1089 1090```typescript 1091import "@typelex/emitter"; 1092 1093namespace com.example { 1094 model Main { 1095 @readOnly status?: string = "active"; 1096 } 1097} 1098``` 1099 1100Maps to: `{"const": "active"}` 1101 1102## Nullable Fields 1103 1104Use `| null` for nullable fields: 1105 1106```typescript 1107import "@typelex/emitter"; 1108 1109namespace 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 1118Output: 1119 1120```json 1121// ... 1122"required": ["createdAt"], 1123"nullable": ["updatedAt"] 1124// ... 1125```