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