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```