An experimental TypeSpec syntax for Lexicon
1# Typelex Docs
2
3This maps [atproto Lexicon](https://atproto.com/specs/lexicon) JSON syntax to typelex (which is a [TypeSpec](https://typespec.io/) emitter). It assumes you're familiar with Lexicon and want to understand how to express it in TypeSpec. Consult [TypeSpec docs](https://typespec.io/) on details of TypeSpec syntax.
4
5This page was mostly written by Claude based on the test fixtures from this repo (which are [deployed in the playground](https://playground.typelex.org/)). I hope it's mostly correct and comprehensible. When in doubt, refer to those fixtures.
6
7## Playground
8
9Go to https://playground.typelex.org/ to play with a bunch of lexicons.
10
11## Quick Start
12
13Every TypeSpec file starts with an import and namespace:
14
15```typescript
16import "@typelex/emitter";
17
18/** Common definitions used by other lexicons */
19namespace com.example.defs {
20 // definitions here
21}
22```
23
24**Maps to:**
25```json
26{
27 "lexicon": 1,
28 "id": "com.example.defs",
29 "description": "Common definitions used by other lexicons",
30 "defs": { ... }
31}
32```
33
34Use `/** */` doc comments for descriptions (or `@doc()` decorator as alternative).
35
36## Top-Level Lexicon Types
37
38### Query (XRPC Query)
39
40```typescript
41namespace com.example.getRecord {
42 /** Retrieve a record by ID */
43 @query
44 op main(
45 /** The record identifier */
46 @required id: string
47 ): {
48 @required record: com.example.record.Main;
49 };
50}
51```
52
53**Maps to:** `{"type": "query", ...}` with `parameters` and `output`
54
55### Procedure (XRPC Procedure)
56
57```typescript
58namespace com.example.createRecord {
59 /** Create a new record */
60 @procedure
61 op main(input: {
62 @required text: string;
63 }): {
64 @required uri: atUri;
65 @required cid: cid;
66 };
67}
68```
69
70**Maps to:** `{"type": "procedure", ...}` with `input` and `output`
71
72### Subscription (XRPC Subscription)
73
74```typescript
75namespace com.example.subscribeRecords {
76 /** Subscribe to record updates */
77 @subscription
78 op main(cursor?: integer): (Record | Delete);
79
80 model Record {
81 @required uri: atUri;
82 @required record: com.example.record.Main;
83 }
84
85 model Delete {
86 @required uri: atUri;
87 }
88}
89```
90
91**Maps to:** `{"type": "subscription", ...}` with `message` containing union
92
93### Record
94
95```typescript
96namespace com.example.post {
97 @rec("tid")
98 /** A post record */
99 model Main {
100 @required text: string;
101 @required createdAt: datetime;
102 }
103}
104```
105
106**Maps to:** `{"type": "record", "key": "tid", "record": {...}}`
107
108**Record key types:** `@rec("tid")`, `@rec("any")`, `@rec("nsid")`
109
110### Object (Plain Definition)
111
112```typescript
113namespace com.example.defs {
114 /** User metadata */
115 model Metadata {
116 version?: integer = 1;
117 tags?: string[];
118 }
119}
120```
121
122**Maps to:** `{"type": "object", "properties": {...}}`
123
124## Reserved Keywords
125
126Use backticks for TypeScript/TypeSpec reserved words:
127
128```typescript
129namespace app.bsky.feed.post.`record` { ... }
130namespace `pub`.leaflet.subscription { ... }
131```
132
133## Inline vs Definitions
134
135**By default, models become separate defs.** Use `@inline` to prevent this:
136
137```typescript
138// Without @inline - becomes separate def "statusEnum"
139union StatusEnum {
140 "active",
141 "inactive",
142}
143
144// With @inline - inlined where used
145@inline
146union StatusEnum {
147 "active",
148 "inactive",
149}
150```
151
152Use `@inline` when you want the type directly embedded rather than referenced.
153
154## Optional vs Required Fields
155
156**In lexicons, optional fields are the norm.** Required fields are discouraged and need explicit `@required`:
157
158```typescript
159model Post {
160 text?: string; // optional (common)
161 @required createdAt: datetime; // required (discouraged, needs decorator)
162}
163```
164
165**Maps to:**
166```json
167{
168 "type": "object",
169 "required": ["createdAt"],
170 "properties": {
171 "text": {"type": "string"},
172 "createdAt": {"type": "string", "format": "datetime"}
173 }
174}
175```
176
177## Primitive Types
178
179| TypeSpec | Lexicon JSON |
180|----------|--------------|
181| `boolean` | `{"type": "boolean"}` |
182| `integer` | `{"type": "integer"}` |
183| `string` | `{"type": "string"}` |
184| `bytes` | `{"type": "bytes"}` |
185| `cidLink` | `{"type": "cid-link"}` |
186| `unknown` | `{"type": "unknown"}` |
187
188## Format Types
189
190Specialized string formats:
191
192| TypeSpec | Lexicon Format |
193|----------|----------------|
194| `atIdentifier` | `at-identifier` - Handle or DID |
195| `atUri` | `at-uri` - AT Protocol URI |
196| `cid` | `cid` - Content ID |
197| `datetime` | `datetime` - ISO 8601 datetime |
198| `did` | `did` - DID identifier |
199| `handle` | `handle` - Handle identifier |
200| `nsid` | `nsid` - Namespaced ID |
201| `tid` | `tid` - Timestamp ID |
202| `recordKey` | `record-key` - Record key |
203| `uri` | `uri` - Generic URI |
204| `language` | `language` - Language tag |
205
206## Unions
207
208### Open Unions (Common Pattern)
209
210**Open unions are the default and preferred in lexicons.** Add `unknown` to mark as open:
211
212```typescript
213model Main {
214 /** Can be any of these types or future additions */
215 @required item: TypeA | TypeB | TypeC | unknown;
216}
217
218model TypeA {
219 @readOnly @required kind: string = "a";
220 @required valueA: string;
221}
222```
223
224**Maps to:**
225```json
226{
227 "properties": {
228 "item": {
229 "type": "union",
230 "refs": ["#typeA", "#typeB", "#typeC"]
231 }
232 }
233}
234```
235
236The `unknown` makes it open but doesn't appear in refs.
237
238### Known Values (Open String Enum)
239
240Suggest values but allow others:
241
242```typescript
243model Main {
244 /** Language - suggests common values but allows any */
245 lang?: "en" | "es" | "fr" | string;
246}
247```
248
249**Maps to:**
250```json
251{
252 "properties": {
253 "lang": {
254 "type": "string",
255 "knownValues": ["en", "es", "fr"]
256 }
257 }
258}
259```
260
261### Closed Unions (Discouraged)
262
263**⚠️ Closed unions are discouraged in lexicons** as they prevent future additions. Use only when absolutely necessary:
264
265```typescript
266@closed
267@inline
268union Action {
269 Create,
270 Update,
271 Delete,
272}
273
274model Main {
275 @required action: Action;
276}
277```
278
279**Maps to:**
280```json
281{
282 "properties": {
283 "action": {
284 "type": "union",
285 "refs": ["#create", "#update", "#delete"],
286 "closed": true
287 }
288 }
289}
290```
291
292### Closed Enums (Discouraged)
293
294**⚠️ Closed enums are also discouraged.** Use `@closed @inline union` for fixed value sets:
295
296```typescript
297@closed
298@inline
299union Status {
300 "active",
301 "inactive",
302 "pending",
303}
304```
305
306**Maps to:**
307```json
308{
309 "type": "string",
310 "enum": ["active", "inactive", "pending"]
311}
312```
313
314Integer enums work the same way:
315
316```typescript
317@closed
318@inline
319union Fibonacci {
320 1, 2, 3, 5, 8,
321}
322```
323
324## Arrays
325
326Use `[]` suffix:
327
328```typescript
329model Main {
330 /** Array of strings */
331 stringArray?: string[];
332
333 /** Array with size constraints */
334 @minItems(1)
335 @maxItems(10)
336 limitedArray?: integer[];
337
338 /** Array of references */
339 items?: Item[];
340
341 /** Array of union types */
342 mixed?: (TypeA | TypeB | unknown)[];
343}
344```
345
346**Maps to:** `{"type": "array", "items": {...}}`
347
348**Note:** `@minItems`/`@maxItems` in TypeSpec map to `minLength`/`maxLength` in JSON.
349
350## Blobs
351
352```typescript
353model Main {
354 /** Basic blob */
355 file?: Blob;
356
357 /** Image up to 5MB */
358 image?: Blob<#["image/*"], 5000000>;
359
360 /** Specific types up to 2MB */
361 photo?: Blob<#["image/png", "image/jpeg"], 2000000>;
362}
363```
364
365**Maps to:**
366```json
367{
368 "file": {"type": "blob"},
369 "image": {
370 "type": "blob",
371 "accept": ["image/*"],
372 "maxSize": 5000000
373 }
374}
375```
376
377## References
378
379### Local References
380
381Same namespace, uses `#`:
382
383```typescript
384model Main {
385 metadata?: Metadata;
386}
387
388model Metadata {
389 @required key: string;
390}
391```
392
393**Maps to:** `{"type": "ref", "ref": "#metadata"}`
394
395### External References
396
397Different namespace to specific def:
398
399```typescript
400model Main {
401 externalRef?: com.example.defs.Metadata;
402}
403```
404
405**Maps to:** `{"type": "ref", "ref": "com.example.defs#metadata"}`
406
407Different namespace to main def (no fragment):
408
409```typescript
410model Main {
411 mainRef?: com.example.post.Main;
412}
413```
414
415**Maps to:** `{"type": "ref", "ref": "com.example.post"}`
416
417## Tokens
418
419Empty models marked with `@token`:
420
421```typescript
422/** Indicates spam content */
423@token
424model ReasonSpam {}
425
426/** Indicates policy violation */
427@token
428model ReasonViolation {}
429
430model Report {
431 @required reason: (ReasonSpam | ReasonViolation | unknown);
432}
433```
434
435**Maps to:**
436```json
437{
438 "report": {
439 "properties": {
440 "reason": {
441 "type": "union",
442 "refs": ["#reasonSpam", "#reasonViolation"]
443 }
444 }
445 },
446 "reasonSpam": {
447 "type": "token",
448 "description": "Indicates spam content"
449 }
450}
451```
452
453## Operation Details
454
455### Query Parameters
456
457```typescript
458@query
459op main(
460 @required search: string,
461 limit?: integer = 50,
462 tags?: string[]
463): { ... };
464```
465
466Parameters can be inline with decorators before each.
467
468### Procedure with Input and Parameters
469
470```typescript
471@procedure
472op main(
473 input: {
474 @required data: string;
475 },
476 parameters: {
477 @required repo: atIdentifier;
478 validate?: boolean = true;
479 }
480): { ... };
481```
482
483Use `input:` for body, `parameters:` for query params.
484
485### No Output
486
487```typescript
488@procedure
489op main(input: {
490 @required uri: atUri;
491}): void;
492```
493
494Use `: void` for procedures with no output.
495
496### Output Without Schema
497
498```typescript
499@query
500@encoding("application/json")
501op main(id?: string): never;
502```
503
504Use `: never` with `@encoding()` for output with encoding but no schema.
505
506### Errors
507
508```typescript
509/** The provided text is invalid */
510model InvalidText {}
511
512/** User not found */
513model NotFound {}
514
515@procedure
516@errors(InvalidText, NotFound)
517op main(...): ...;
518```
519
520Empty models with descriptions become error definitions.
521
522## Constraints
523
524### String Constraints
525
526```typescript
527model Main {
528 /** Byte length constraints */
529 @minLength(1)
530 @maxLength(100)
531 text?: string;
532
533 /** Grapheme cluster length constraints */
534 @minGraphemes(1)
535 @maxGraphemes(50)
536 displayName?: string;
537}
538```
539
540**Maps to:** `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes`
541
542### Integer Constraints
543
544```typescript
545model Main {
546 @minValue(1)
547 @maxValue(100)
548 score?: integer;
549}
550```
551
552**Maps to:** `minimum`/`maximum`
553
554### Bytes Constraints
555
556```typescript
557model Main {
558 @minBytes(1)
559 @maxBytes(1024)
560 data?: bytes;
561}
562```
563
564**Maps to:** `minLength`/`maxLength`
565
566**Note:** Use `@minBytes`/`@maxBytes` in TypeSpec, but they map to `minLength`/`maxLength` in JSON.
567
568### Array Constraints
569
570```typescript
571model Main {
572 @minItems(1)
573 @maxItems(10)
574 items?: string[];
575}
576```
577
578**Maps to:** `minLength`/`maxLength`
579
580**Note:** Use `@minItems`/`@maxItems` in TypeSpec, but they map to `minLength`/`maxLength` in JSON.
581
582## Default and Constant Values
583
584### Defaults
585
586```typescript
587model Main {
588 version?: integer = 1;
589 lang?: string = "en";
590}
591```
592
593**Maps to:** `{"default": 1}`, `{"default": "en"}`
594
595### Constants
596
597Use `@readOnly` with default value:
598
599```typescript
600model Main {
601 @readOnly status?: string = "active";
602}
603```
604
605**Maps to:** `{"const": "active"}`
606
607## Nullable Fields
608
609Use `| null` for nullable fields:
610
611```typescript
612model Main {
613 @required createdAt: datetime;
614 updatedAt?: datetime | null; // can be omitted or null
615 deletedAt?: datetime; // can only be omitted
616}
617```
618
619**Maps to:**
620```json
621{
622 "required": ["createdAt"],
623 "nullable": ["updatedAt"],
624 "properties": { ... }
625}
626```
627
628## Common Patterns
629
630### Discriminated Unions
631
632Use `@readOnly` with const for discriminator:
633
634```typescript
635model Create {
636 @readOnly @required type: string = "create";
637 @required data: string;
638}
639
640model Update {
641 @readOnly @required type: string = "update";
642 @required id: string;
643}
644```
645
646### Nested Unions
647
648```typescript
649model Container {
650 @required id: string;
651 @required payload: (PayloadA | PayloadB | unknown);
652}
653```
654
655Unions can be nested in objects and arrays.
656
657## Naming Conventions
658
659Model names convert from PascalCase to camelCase in defs:
660
661```typescript
662model StatusEnum { ... } // becomes "statusEnum"
663model UserMetadata { ... } // becomes "userMetadata"
664model Main { ... } // becomes "main"
665```
666
667## Decorator Style
668
669- Single `@required` goes on same line: `@required text: string`
670- Multiple decorators go on separate lines with blank line after:
671 ```typescript
672 @minLength(1)
673 @maxLength(100)
674 text?: string;
675 ```