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