···11# typelex docs
2233-typelex is a [TypeSpec](https://typespec.io/) emitter targeting [atproto Lexicon](https://atproto.com/specs/lexicon) JSON as the output format.
33+Typelex is a [TypeSpec](https://typespec.io/) emitter that outputs [AT Lexicon](https://atproto.com/specs/lexicon) JSON files.
44+55+## Introduction
66+77+### What's TypeSpec?
88+99+[TypeSpec](https://typespec.io/) is a TypeScript-like language for writing schemas for data and API calls.
1010+1111+TypeSpec offers flexible syntax for describing schemas, as well as the tooling for it (like LSP), but it doesn't specify the semantics or the output format. It can target different output formats via *emitters*. 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/) for TypeSpec. Emitters determine the output format. Emitters can define or restrict the available built-in types. Emitters can also define their own *decorators* that attach to different pieces of syntax.
1212+1313+### What's Lexicon?
1414+1515+[Lexicon](https://atproto.com/specs/lexicon) is a schema format used by [AT](https://atproto.com/) applications.
1616+1717+Here is a small Lexicon schema defining an `app.bsky.bookmark.defs` Lexicon containing a `listItemView` definition, which describes an `object` with a required `uri` property of type `at-uri`:
1818+1919+```json
2020+{
2121+ "lexicon": 1,
2222+ "id": "app.bsky.bookmark.defs",
2323+ "defs": {
2424+ "listItemView": {
2525+ "type": "object",
2626+ "properties": {
2727+ "uri": { "type": "string", "format": "at-uri" }
2828+ },
2929+ "required": ["uri"]
3030+ }
3131+ }
3232+}
3333+```
3434+3535+You would then generate code from this schema that takes care of parsing and validating a piece of data of that shape, as well as the type definitions (e.g. for Go or TypeScript).
3636+3737+### What's typelex?
3838+3939+Typelex is a TypeSpec emitter that targets AT Lexicon as the output format. As such, it adds built-in Lexicon data types and a few decorators to control the structure of the Lexicon output.
4040+4141+Here's the above Lexicon written in TypeSpec (with the typelex emitter):
4242+4343+```typescript
4444+import "@typelex/emitter";
44555-This page 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.
4646+namespace app.bsky.bookmark.defs {
4747+ model ListItemView {
4848+ @required uri: atUri;
4949+ }
5050+}
5151+```
5252+5353+Then you run the compiler, and it generates the Lexicon JSON for you.
5454+5555+It is important to note that the JSON format is in which you'll publish your Lexicons. Typelex only exists for authoring convenience and does not aim to supplant or replace Lexicon JSON itself. Think of it as a "CoffeeScript for Lexicon" (however terrible that may be).
65677-This 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.
5757+Typelex tries to stay faithful to both TypeSpec idioms and Lexicon concepts, which makes it a bit confusing and sometimes not elegant. Since we can't add new keywords to TypeSpec, often there's a decorator that fills the need. For example, you'll have to write `@procedure op` whereas ideally there would just be a `procedure` keyword, or you'll have to write `model` for something that Lexicon calls a "def". In essence, you have to learn both Lexicon *and* TypeSpec. It is a good idea to scan through the [TypeSpec language overview](https://typespec.io/docs/language-basics/overview/) to get a feel for it.
85899-## What is this?
5959+Personally, I find JSON Lexicons difficult to read and to author so the tradeoff is worth it for me.
10601111-It's just a weekend experiment. Paul said [I can give it a try](https://bsky.app/profile/pfrazee.com/post/3m2asobpr422b) so I figured [why not](https://underreacted.leaflet.pub/3m23gqakbqs2j).
6161+### Playground
12621313-## Playground
6363+[Open Playground](https://playground.typelex.org/) to play with a bunch of lexicons and to see how typelex code translates to Lexicon JSON. If you already know Lexicon, you can learn the syntax by just opening the Lexicons you're familiar with.
14641515-[Open Playground](https://playground.typelex.org/) to play with a bunch of lexicons.
6565+## Quick Start
16661717-If you already know Lexicon, you can learn the syntax by just opening the Lexicons you're familiar with.
6767+Let's dive into some common patterns and see how to write them.
18681919-## Namespaces
6969+### Namespaces
20702121-Every TypeSpec file starts with an import and namespace:
7171+A namespace corresponds to a Lexicon output file. For example:
22722373```typescript
2474import "@typelex/emitter";
25752626-/** Some docstring */
2727-namespace com.example.defs {
2828- // definitions here
7676+namespace app.bsky.feed.defs {
7777+ model PostView {
7878+ // ...
7979+ }
2980}
3081```
31823232-**Maps to:**
8383+will emit `app/bsky/feed/defs.json`:
8484+3385```json
3486{
3587 "lexicon": 1,
3636- "id": "com.example.defs",
3737- "description": "Some docstring",
3838- "defs": { ... }
8888+ "id": "app.bsky.feed.defs",
8989+ // ...
3990}
4091```
41924242-Use `/** */` doc comments for descriptions (or `@doc()` decorator as alternative).
9393+You can [try it in the playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5mZWVkLmRlZnMgewogIG1vZGVsIFBvc3RWaWV3xRMgIHJlcGx5Q291bnQ%2FOiBpbnRlZ2VyO8gab3N01RtsaWtl0xl9Cn0K&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D).
43944444-## Reserved Words
9595+### Reserved Words
45964697Use backticks for TypeScript/TypeSpec reserved words:
47984899```typescript
4949-namespace app.bsky.feed.post.`record` {
5050- ...
5151-}
100100+namespace app.bsky.feed.post.`record` { }
101101+102102+namespace `pub`.blocks.blockquote { }
52103```
531045454-## Many Lexicons in One File
105105+### Multiple Namespaces in One File
5510656107Multiple namespaces can be defined in one file:
57108···61112namespace com.example.foo {
62113 @rec("tid")
63114 model Main {
6464- bar?: com.example.bar.Main
115115+ bar?: com.example.bar.Main
65116 }
66117}
67118···73124}
74125```
751267676-This single `.tsp` file will turn into two Lexicon files in the output folder:
127127+This single `.tsp` file will emit two Lexicon files:
7712878129```
79130- com/example/foo.json
80131- com/example/bar.json
81132```
821338383-You can see this in action in the Playground, where every example is written as a single input file. For example, [this `app.bsky.actor.profile` example](https://playground.typelex.org/?sample=app.bsky.actor.profile) "spits out" multiple outputs like `app/bsky/actor/profile.json`, `com/atproto/label/defs.json`, and so on, which you can see in the right pane.
8484-8585-## Dependencies
134134+You can `import` other `.tsp` files to avoid defining the same thing multiple times. The output structure is only determined by namespaces, not by how you split your input files.
861358787-You can `import` other `.tsp` files. This lets you avoid defining the same thing many times if you want to refer to it from many files.
136136+## Models
881378989-For example:
138138+A namespace may contain [`model`s](https://typespec.io/docs/language-basics/models/) inside. By default, **every `model` turns into a Lexicon definition** (`defs` in the Lexicon output). For example, if you have a `PostView` model defined like this:
9013991140```typescript
9292-// main.tsp
9393-94141import "@typelex/emitter";
9595-import "./bar.tsp";
961429797-namespace com.example.foo {
9898- @rec("tid")
9999- model Main {
100100- bar?: com.example.bar.Main
143143+namespace app.bsky.feed.defs {
144144+ model PostView {
145145+ // ...
146146+ }
147147+}
148148+```
149149+150150+It will then become `postView` definition in the `defs` of this namespace's Lexicon:
151151+152152+```json
153153+{
154154+ "lexicon": 1,
155155+ "id": "app.bsky.feed.defs",
156156+ "defs": {
157157+ "postView": {
158158+ // ...
159159+ }
101160 }
102161}
103103-````
162162+```
163163+164164+A namespace may have multiple `model`s:
104165105166```typescript
106106-// bar.tsp
107107-namespace com.example.bar {
108108- model Main {
109109- @maxGraphemes(1)
110110- bla?: string;
167167+namespace app.bsky.feed.defs {
168168+ model PostView {
169169+ // ...
170170+ }
171171+172172+ model ViewerState {
173173+ // ...
111174 }
112175}
113113-````
176176+```
114177115115-It doesn't matter how you split input into files. The output structure is only determined by namespaces.
178178+Every `model` becomes an entry in `defs`:
116179117117-## Stubs
180180+```json
181181+{
182182+ "lexicon": 1,
183183+ "id": "app.bsky.feed.defs",
184184+ "defs": {
185185+ "postView": {
186186+ // ...
187187+ },
188188+ "viewerState": {
189189+ // ..
190190+ }
191191+ }
192192+}
193193+```
118194119119-For now, we're assuming you write everything in TypeSpec. You can grab common Lexicons from the [Playground](https://playground.typelex.org/). However, if you just want a ref, you could stub out any external Lexicon, like this:
195195+This works even if they're declared in separate `namespace` blocks:
120196121197```typescript
122122-namespace com.example.defs {
123123- subject: com.atproto.repo.strongRef.Main; // OK to use here
198198+namespace app.bsky.feed.defs {
199199+ model PostView {
200200+ // ...
201201+ }
124202}
125203126126-// This is a stub!
127127-namespace com.atproto.repo.strongRef {
128128- model Main { } // It's fine to leave this empty
204204+namespace app.bsky.feed.defs {
205205+ model ViewerState {
206206+ // ...
207207+ }
129208}
130209```
131210132132-Maybe this should be more ergonomic. I haven't tried this in a real project so I don't know what workflows are good.
211211+These blocks could even be in different files, one [`import`ing](https://typespec.io/docs/language-basics/imports/) the other.
133212134134-## Top-Level Lexicon Types
213213+In either case, TypeSpec will find all the models inside each namespace (such as `app.bsky.feed.defs`), and bundle each namespace into a separate Lexicon file (such as `app/bsky/feed/defs.json`) with all its `defs`.
135214136136-In TypeSpec, definitions of things are called [Models](https://typespec.io/docs/language-basics/models/).
215215+### Namespace Conventions: `.defs` vs `Main`
137216138138-So you'll see `model Foo { }` used for almost everything, but with different decorators that make its purpose more conrete.
217217+By Lexicon convention, a namespace must either have a `Main` model or end with `.defs`.
139218140140-### Record
219219+You should end your namespace in `.defs` if you want a grabbag of reusable definitions:
141220142221```typescript
143143-namespace com.example.post {
144144- @rec("tid")
145145- model Main {
146146- @required text: string;
147147- @required createdAt: datetime;
222222+namespace app.bsky.feed.defs {
223223+ model PostView {
224224+ // ...
225225+ }
226226+227227+ model ViewerState {
228228+ // ...
148229 }
149230}
150231```
151232152152-Note it's `@rec` because (unfortunately) `record` is reserved in TypeSpec.
233233+On the other hand, if your Lexicon is about one main concept, don't add `.defs` to the namespace, and instead pick some model to be called `Main`:
153234154154-**Maps to:** `{"type": "record", "key": "tid", "record": {...}}`
235235+```typescript
236236+namespace app.bsky.embed.video {
237237+ model Main {
238238+ @required
239239+ video: Blob<#["video/mp4"], 100000000>;
155240156156-**Record key types:** `@rec("tid")`, `@rec("any")`, `@rec("nsid")`
241241+ @maxItems(20)
242242+ captions?: Caption[];
243243+ }
157244158158-### Object (Plain Definition)
159159-160160-```typescript
161161-namespace com.example.defs {
162162- /** User metadata */
163163- model Metadata {
164164- version?: integer = 1;
165165- tags?: string[];
245245+ model Caption {
246246+ // ...
166247 }
167248}
168249```
169250170170-**Maps to:** `{"type": "object", "properties": {...}}`
251251+Either do one or the other, or you'll get a compile error forcing you to choose.
252252+253253+### References
171254172172-### Query (XRPC Query)
255255+A `model` can reference another `model` as a data type like so:
173256174257```typescript
175175-namespace com.example.getRecord {
176176- /** Retrieve a record by ID */
177177- @query
178178- op main(
179179- /** The record identifier */
180180- @required id: string
181181- ): {
182182- @required record: com.example.record.Main;
183183- };
258258+namespace app.bsky.embed.video {
259259+ model Main {
260260+ // A reference to the `Caption` model below:
261261+ captions?: Caption[];
262262+ }
263263+264264+ model Caption {
265265+ // ...
266266+ }
184267}
185268```
186269187187-**Maps to:** `{"type": "query", ...}` with `parameters` and `output`
270270+In the resulting Lexicon JSON, this becomes a `ref` to the local `#caption` def:
271271+272272+```json
273273+{
274274+ "lexicon": 1,
275275+ "id": "app.bsky.embed.video",
276276+ "defs": {
277277+ "main": {
278278+ "type": "object",
279279+ "properties": {
280280+ "captions": {
281281+ "type": "array",
282282+ "items": {
283283+ // A reference to the `caption` def below:
284284+ "type": "ref",
285285+ "ref": "#caption"
286286+ }
287287+ }
288288+ }
289289+ },
290290+ "caption": {
291291+ "type": "object",
292292+ "properties": {}
293293+ }
294294+ }
295295+}
296296+```
188297189189-### Procedure (XRPC Procedure)
298298+You can also reference `model`s from other namespaces:
190299191300```typescript
192192-namespace com.example.createRecord {
193193- /** Create a new record */
194194- @procedure
195195- op main(input: {
196196- @required text: string;
197197- }): {
198198- @required uri: atUri;
199199- @required cid: cid;
200200- };
301301+import "@typelex/emitter";
302302+303303+namespace app.bsky.actor.profile {
304304+ model Main {
305305+ // A reference to the `SelfLabel` model from another Lexicon:
306306+ labels?: (com.atproto.label.defs.SelfLabels | unknown);
307307+ }
308308+}
309309+310310+namespace com.atproto.label.defs {
311311+ model SelfLabels {
312312+ // ...
313313+ }
201314}
202315```
203316204204-**Maps to:** `{"type": "procedure", ...}` with `input` and `output`
317317+This will become a fully qualified reference:
318318+319319+```json
320320+// ...
321321+"labels": {
322322+ "type": "union",
323323+ "refs": ["com.atproto.label.defs#selfLabels"]
324324+}
325325+// ...
326326+```
327327+328328+This will work even if you move the `com.atproto.label.defs` definition to another `.tsp` file, as long as you don't forget to `import` that file.
329329+330330+As you can see [in the Playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5hY3Rvci5wcm9maWxlIHsKICBAcmVjKCJsaXRlcmFsOnNlbGYiKQogIG1vZGVsIE1haW7FJiAgZGlzcGxheU5hbWU%2FOiBzdHJpbmc7xRpsYWJlbHM%2FOiAoY29tLmF0cHJvdG8uxRYuZGVmcy5TZWxmTMUlIHwgdW5rbm93binEPH0KfewAptY%2F5QCA5gCPy0nEFSAgLy8gLi4uxko%3D&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D), this actually emits *two Lexicon files* since there are two separate namespaces being declared.
331331+332332+This makes sense if both definitions are yours, but what if you just want to *refer to* someone else's Lexicon?
333333+334334+### External Stubs
205335206206-### Subscription (XRPC Subscription)
336336+The easiest way to use someone else's Lexicon is to import its definition, assuming it's also written in TypeSpec.
207337208338```typescript
209209-namespace com.example.subscribeRecords {
210210- /** Subscribe to record updates */
211211- @subscription
212212- op main(cursor?: integer): (Record | Delete);
339339+import "../atproto.tsp"
340340+```
341341+342342+In practice, you probably won't have all the definitions of the Lexicons you depend on written in TypeSpec. However, you can **stub out any definition you depend on**:
343343+344344+```typescript
345345+import "@typelex/emitter";
213346214214- model Record {
215215- @required uri: atUri;
216216- @required record: com.example.record.Main;
347347+namespace app.bsky.actor.profile {
348348+ model Main {
349349+ // A reference to a stub
350350+ labels?: (com.atproto.label.defs.SelfLabels | unknown);
217351 }
352352+}
218353219219- model Delete {
220220- @required uri: atUri;
354354+// This is a stub! It's fine for it to be empty.
355355+// Think of it similarly to a .d.ts definition in TypeScript.
356356+namespace com.atproto.label.defs {
357357+ model SelfLabels { }
358358+}
359359+```
360360+361361+You could place stubs together and import them from the definitions that need them, and then ignore the (incomplete) Lexicon JSON output for those stubs (since it's going to be empty).
362362+363363+```typescript
364364+import "@typelex/emitter";
365365+import "../atproto-stubs.tsp"; // All your stubs here
366366+367367+namespace app.bsky.actor.profile {
368368+ model Main {
369369+ // A reference to a stub
370370+ labels?: (com.atproto.label.defs.SelfLabels | unknown);
221371 }
222372}
223373```
224374225225-**Maps to:** `{"type": "subscription", ...}` with `message` containing union
375375+I'm not sure which organizational patterns work best in practice. Try different things and let me know.
376376+377377+### Inline Models
226378227227-## Inline vs Definitions
379379+By default, every `model` becomes a top-level entry in Lexicon `defs`.
228380229229-**By default, all TypeSpec `model`s become separate Lexicon `defs`.** Use `@inline` to prevent this:
381381+This:
230382231383```typescript
232232-// Without @inline - becomes separate def "statusEnum"
233233-union StatusEnum {
234234- "active",
235235- "inactive",
236236-}
384384+import "@typelex/emitter";
385385+386386+namespace app.bsky.embed.video {
387387+ model Main {
388388+ captions?: Caption[];
389389+ }
237390238238-// With @inline - inlined where used
239239-@inline
240240-union StatusEnum {
241241- "active",
242242- "inactive",
391391+ model Caption {
392392+ text?: string
393393+ }
243394}
244395```
245396246246-Use `@inline` when you want the type directly embedded rather than referenced.
397397+turns into:
247398248248-## Optional vs Required Fields
399399+```json
400400+{
401401+ "lexicon": 1,
402402+ "id": "app.bsky.embed.video",
403403+ "defs": {
404404+ "main": {
405405+ // ...
406406+ },
407407+ "caption": {
408408+ // ...
409409+ }
410410+ }
411411+}
412412+```
249413250250-**In lexicons, optional fields are the norm.** Required fields are discouraged and thus need explicit `@required`:
414414+Sometimes, you might want to avoid exposing a model as its own `def`, and you just want it to be expanded inline. Put the `@inline` decorator on the `model` to force it into being inlined at every usage site:
251415252416```typescript
253253-model Post {
254254- text?: string; // optional (common)
255255- @required createdAt: datetime; // required (discouraged, needs decorator)
417417+import "@typelex/emitter";
418418+419419+namespace app.bsky.embed.video {
420420+ model Main {
421421+ captions?: Caption[];
422422+ }
423423+424424+ @inline
425425+ model Caption {
426426+ text?: string
427427+ }
256428}
257429```
258430259259-**Maps to:**
431431+Then it will be inlined wherever it's being used:
432432+260433```json
261434{
262262- "type": "object",
263263- "required": ["createdAt"],
264264- "properties": {
265265- "text": {"type": "string"},
266266- "createdAt": {"type": "string", "format": "datetime"}
435435+ "lexicon": 1,
436436+ "id": "app.bsky.embed.video",
437437+ "defs": {
438438+ "main": {
439439+ "type": "object",
440440+ "properties": {
441441+ "captions": {
442442+ "type": "array",
443443+ "items": {
444444+ // That's our Caption, inlined.
445445+ "type": "object",
446446+ "properties": {
447447+ "text": { "type": "string" }
448448+ }
449449+ }
450450+ }
451451+ }
452452+ }
267453 }
268454}
269455```
270456271271-(TypeSpec doesn't require this but I figured we want to make it extra hard to accidentally make a required field.)
457457+Note this means that different usages of `Caption` will have no relation to each other from the Lexicon perspective. The fact that the `Caption` abstraction exists will essentially be erased.
272458273273-## Primitive Types
459459+## Top-Level Lexicon Types
460460+461461+In TypeSpec, definitions of things are called [Models](https://typespec.io/docs/language-basics/models/). So you'll see `model Foo { }` used for almost everything, but with different decorators that make its purpose more concrete.
462462+463463+### Objects
274464275275-| TypeSpec | Lexicon JSON |
276276-|----------|--------------|
277277-| `boolean` | `{"type": "boolean"}` |
278278-| `integer` | `{"type": "integer"}` |
279279-| `string` | `{"type": "string"}` |
280280-| `bytes` | `{"type": "bytes"}` |
281281-| `cidLink` | `{"type": "cid-link"}` |
282282-| `unknown` | `{"type": "unknown"}` |
465465+If you haven't marked your `model` with any decorator, it will be a Lexicon *object*.
283466284284-## Format Types
467467+```typescript
468468+namespace com.example.post {
469469+ model Main {
470470+ // ...
471471+ }
472472+}
473473+```
285474286286-Specialized string formats:
475475+becomes
287476288288-| TypeSpec | Lexicon Format |
289289-|----------|----------------|
290290-| `atIdentifier` | `at-identifier` - Handle or DID |
291291-| `atUri` | `at-uri` - AT Protocol URI |
292292-| `cid` | `cid` - Content ID |
293293-| `datetime` | `datetime` - ISO 8601 datetime |
294294-| `did` | `did` - DID identifier |
295295-| `handle` | `handle` - Handle identifier |
296296-| `nsid` | `nsid` - Namespaced ID |
297297-| `tid` | `tid` - Timestamp ID |
298298-| `recordKey` | `record-key` - Record key |
299299-| `uri` | `uri` - Generic URI |
300300-| `language` | `language` - Language tag |
477477+```json
478478+{
479479+ "lexicon": 1,
480480+ "id": "com.example.post",
481481+ "defs": {
482482+ "main": {
483483+ "type": "object",
484484+ "properties": {
485485+ // ...
486486+ }
487487+ }
488488+ }
489489+}
490490+```
301491302302-## Unions
492492+### Records
303493304304-### Open Unions (Common Pattern)
494494+Mark a `model` with a `@rec` decorator to make it a Lexicon *record*.
305495306306-**Open unions are the default and preferred in lexicons.** Add `unknown` to mark as open:
496496+For example:
307497308498```typescript
309309-model Main {
310310- /** Can be any of these types or future additions */
311311- @required item: TypeA | TypeB | TypeC | unknown;
312312-}
499499+import "@typelex/emitter"; // Don't forget this import!
313500314314-model TypeA {
315315- @readOnly @required kind: string = "a";
316316- @required valueA: string;
501501+namespace com.example.post {
502502+ @rec("tid")
503503+ model Main {
504504+ // ...
505505+ }
317506}
318507```
319508320320-**Maps to:**
509509+(Note it's `@rec` and not `@record` because unfortunately "record" is reserved in TypeSpec.)
510510+511511+This becomes:
512512+321513```json
322514{
323323- "properties": {
324324- "item": {
325325- "type": "union",
326326- "refs": ["#typeA", "#typeB", "#typeC"]
515515+ "lexicon": 1,
516516+ "id": "com.example.post",
517517+ "defs": {
518518+ "main": {
519519+ "type": "record",
520520+ "key": "tid",
521521+ "record": {
522522+ "type": "object",
523523+ "properties": {
524524+ // ...
525525+ }
526526+ }
327527 }
328528 }
329529}
330530```
331531332332-The `unknown` makes it open but doesn't appear in refs.
532532+You can pass any [Record Key Type](https://atproto.com/specs/record-key) to `@rec`, like `@rec("nsid")`, `@rec("literal:self")`, etc.
333533334334-### Known Values (Open String Enum)
534534+### Queries
535535+536536+In TypeSpec, functions are defined with [`op`](https://typespec.io/docs/language-basics/operations/) (for "operation"). Since Lexicon distinguishes *queries* and *procedures*, you need to mark an `op` with either `@query` or `@procedure`.
335537336336-Suggest values but allow others:
538538+Here is an example query:
337539338540```typescript
339339-model Main {
340340- /** Language - suggests common values but allows any */
341341- lang?: "en" | "es" | "fr" | string;
541541+import "@typelex/emitter";
542542+543543+// examples/com/atproto/repo/getRecord.tsp
544544+namespace com.atproto.repo.getRecord {
545545+ @query
546546+ op main(
547547+ @required repo: atIdentifier,
548548+ @required collection: nsid,
549549+ @required rkey: recordKey,
550550+ cid?: cid
551551+ ): {
552552+ @required uri: atUri;
553553+ cid?: cid;
554554+ };
342555}
343556```
344557345345-**Maps to:**
558558+The sequential arguments to `main` are rendered as `params` in the generated JSON, while the return type becomes its `output`:
559559+346560```json
347561{
348348- "properties": {
349349- "lang": {
350350- "type": "string",
351351- "knownValues": ["en", "es", "fr"]
562562+ "lexicon": 1,
563563+ "id": "com.atproto.repo.getRecord",
564564+ "defs": {
565565+ "main": {
566566+ "type": "query",
567567+ "parameters": {
568568+ "type": "params",
569569+ "properties": {
570570+ "repo": { /* ... */ },
571571+ "collection": { /* ... */ },
572572+ "rkey": { /* ... */ },
573573+ "cid": { /* ... */ }
574574+ },
575575+ "required": ["repo", "collection", "rkey"]
576576+ },
577577+ "output": {
578578+ "encoding": "application/json",
579579+ "schema": {
580580+ "type": "object",
581581+ "properties": {
582582+ "uri": { /* ... */ },
583583+ "cid": { /* ... */ }
584584+ },
585585+ "required": ["uri"]
586586+ }
587587+ }
352588 }
353589 }
354590}
355591```
356592357357-### Closed Unions (Discouraged)
593593+Note that `encoding` is assumed to be `"application/json"`. You can override it with the `@encoding` decorator:
358594359359-**⚠️ Closed unions are discouraged in lexicons** as they prevent future additions. Use only when absolutely necessary:
595595+```typescript
596596+@query
597597+@encoding("foo/bar")
598598+op main(
599599+ // ...
600600+```
601601+602602+You may also declare `errors` with the `@errors` decorator like so:
360603361604```typescript
362362-@closed
363363-@inline
364364-union Action {
365365- Create,
366366- Update,
367367- Delete,
605605+import "@typelex/emitter";
606606+607607+namespace com.atproto.repo.getRecord {
608608+ @query
609609+ @errors(FooError, BarError)
610610+ op main(
611611+ // ...
612612+ ): {
613613+ // ...
614614+ };
615615+616616+ model FooError {}
617617+ model BarError {}
368618}
619619+```
620620+621621+If you don't like writing the output definition inline as the `main` return type, you can extract it to a separate `model`. You'll probably want to mark that model as `@inline` so it doesn't clutter the `defs`.
622622+623623+### Procedures
369624370370-model Main {
371371- @required action: Action;
625625+Procedures are declared with `@procedure op`:
626626+627627+```typescript
628628+import "@typelex/emitter";
629629+630630+namespace com.example.createRecord {
631631+ /** Create a new record */
632632+ @procedure
633633+ op main(input: {
634634+ @required text: string;
635635+ }): {
636636+ @required uri: atUri;
637637+ @required cid: cid;
638638+ };
372639}
373640```
374641375375-**Maps to:**
642642+Note that, unlike with queries, you can't change the argument names. The first argument *must* be called `input`. It will correspond to the `input` section inside the procedure's JSON:
643643+376644```json
377645{
378378- "properties": {
379379- "action": {
380380- "type": "union",
381381- "refs": ["#create", "#update", "#delete"],
382382- "closed": true
646646+ "lexicon": 1,
647647+ "id": "com.example.createRecord",
648648+ "defs": {
649649+ "main": {
650650+ "type": "procedure",
651651+ "description": "Create a new record",
652652+ "input": {
653653+ "encoding": "application/json",
654654+ "schema": {
655655+ "type": "object",
656656+ "properties": {
657657+ "text": { "type": "string" }
658658+ },
659659+ "required": ["text"]
660660+ }
661661+ },
662662+ "output": {
663663+ "encoding": "application/json",
664664+ "schema": {
665665+ "type": "object",
666666+ "properties": {
667667+ "uri": { /* ... */ },
668668+ "cid": { /* ... */ }
669669+ },
670670+ "required": ["uri", "cid"]
671671+ }
672672+ }
383673 }
384674 }
385675}
386676```
387677388388-### Closed Enums (Discouraged)
678678+Although this is very rarely done, procedures can also receive parameters (like queries). However, they are declared inside an optional second object argument like `@procedure op(input: {}, parameters: {})`.
679679+680680+Use `: void` for procedures with no output:
681681+682682+```typescript
683683+@procedure
684684+op main(input: {
685685+ @required uri: atUri;
686686+}): void;
687687+```
389688390390-**⚠️ Closed enums are also discouraged.** Use `@closed @inline union` for fixed value sets:
689689+Use `: never` with `@encoding()` for output with encoding but no schema:
391690392691```typescript
393393-@closed
394394-@inline
395395-union Status {
396396- "active",
397397- "inactive",
398398- "pending",
692692+@query
693693+@encoding("application/json")
694694+op main(id?: string): never;
695695+```
696696+697697+### Subscriptions
698698+699699+Defining an `op` with `@subscription` gives you a subscription:
700700+701701+```typescript
702702+import "@typelex/emitter";
703703+704704+namespace com.atproto.sync.subscribeRepos {
705705+ @subscription
706706+ @errors(FutureCursor, ConsumerTooSlow)
707707+ op main(
708708+ cursor?: integer
709709+ ): Commit | Sync | unknown;
710710+711711+ model Commit {
712712+ // ...
713713+ }
714714+715715+ model Sync {
716716+ // ...
717717+ }
718718+719719+ model FutureCursor {}
720720+ model ConsumerTooSlow {}
399721}
400722```
401723402402-**Maps to:**
724724+This becomes:
725725+403726```json
404727{
405405- "type": "string",
406406- "enum": ["active", "inactive", "pending"]
728728+ "lexicon": 1,
729729+ "id": "com.atproto.sync.subscribeRepos",
730730+ "defs": {
731731+ "main": {
732732+ "type": "subscription",
733733+ "parameters": {
734734+ "type": "params",
735735+ "properties": {
736736+ "cursor": { /* ... */ }
737737+ }
738738+ },
739739+ "message": {
740740+ "schema": {
741741+ "type": "union",
742742+ "refs": ["#commit", "#sync"]
743743+ }
744744+ },
745745+ "errors": [{ "name": "FutureCursor" }, { "name": "ConsumerTooSlow" }]
746746+ },
747747+ "commit": { /* ... */ },
748748+ "sync": { /* ... */ }
749749+ }
407750}
408751```
409752410410-Integer enums work the same way:
753753+### Tokens
754754+755755+Empty models marked with `@token` become token definitions:
411756412757```typescript
413413-@closed
414414-@inline
415415-union Fibonacci {
416416- 1, 2, 3, 5, 8,
758758+/** Indicates spam content */
759759+@token
760760+model ReasonSpam {}
761761+762762+/** Indicates policy violation */
763763+@token
764764+model ReasonViolation {}
765765+766766+model Report {
767767+ @required reason: (ReasonSpam | ReasonViolation | unknown);
768768+}
769769+```
770770+771771+**Maps to:**
772772+```json
773773+{
774774+ "report": {
775775+ "properties": {
776776+ "reason": {
777777+ "type": "union",
778778+ "refs": ["#reasonSpam", "#reasonViolation"]
779779+ }
780780+ }
781781+ },
782782+ "reasonSpam": {
783783+ "type": "token",
784784+ "description": "Indicates spam content"
785785+ }
417786}
418787```
419788420420-## Arrays
789789+## Data Types
790790+791791+All [Lexicon data types](https://atproto.com/specs/lexicon#overview-of-types) are supported.
792792+793793+### Primitive Types
794794+795795+| TypeSpec | Lexicon JSON |
796796+|----------|--------------|
797797+| `boolean` | `{"type": "boolean"}` |
798798+| `integer` | `{"type": "integer"}` |
799799+| `string` | `{"type": "string"}` |
800800+| `bytes` | `{"type": "bytes"}` |
801801+| `cidLink` | `{"type": "cid-link"}` |
802802+| `unknown` | `{"type": "unknown"}` |
803803+804804+### Format Types
805805+806806+Specialized string formats:
807807+808808+| TypeSpec | Lexicon Format |
809809+|----------|----------------|
810810+| `atIdentifier` | `at-identifier` - Handle or DID |
811811+| `atUri` | `at-uri` - AT Protocol URI |
812812+| `cid` | `cid` - Content ID |
813813+| `datetime` | `datetime` - ISO 8601 datetime |
814814+| `did` | `did` - DID identifier |
815815+| `handle` | `handle` - Handle identifier |
816816+| `nsid` | `nsid` - Namespaced ID |
817817+| `tid` | `tid` - Timestamp ID |
818818+| `recordKey` | `record-key` - Record key |
819819+| `uri` | `uri` - Generic URI |
820820+| `language` | `language` - Language tag |
821821+822822+### Arrays
421823422824Use `[]` suffix:
423825···443845444846**Note:** `@minItems`/`@maxItems` in TypeSpec map to `minLength`/`maxLength` in JSON.
445847446446-## Blobs
848848+### Blobs
447849448850```typescript
449851model Main {
···470872}
471873```
472874473473-## References
875875+## Required and Optional Fields
474876475475-### Local References
476476-477477-Same namespace, uses `#`:
877877+In Lexicon, the default is to make fields optional. Use `?:` for that:
478878479879```typescript
480480-model Main {
481481- metadata?: Metadata;
482482-}
880880+import "@typelex/emitter";
483881484484-model Metadata {
485485- @required key: string;
882882+namespace tools.ozone.moderation.defs {
883883+ model SubjectStatusView {
884884+ subjectBlobCids?: cid[];
885885+ subjectRepoHandle?: string;
886886+ }
486887}
487888```
488889489489-**Maps to:** `{"type": "ref", "ref": "#metadata"}`
890890+This becomes:
490891491491-### External References
892892+```json
893893+{
894894+ "lexicon": 1,
895895+ "id": "tools.ozone.moderation.defs",
896896+ "defs": {
897897+ "subjectStatusView": {
898898+ "type": "object",
899899+ "properties": {
900900+ "subjectBlobCids": {
901901+ "type": "array",
902902+ "items": { "type": "string", "format": "cid" }
903903+ },
904904+ "subjectRepoHandle": { "type": "string" }
905905+ }
906906+ }
907907+ }
908908+}
909909+```
492910493493-Different namespace to specific def:
911911+Think twice before adding a required field because you won't be able to make it optional later without violating the schema contract (and making all existing records invalid). In practice, this often means you'd have to deprecate the field and create another field, which is messy.
912912+913913+This is why required fields need a special `@required` decorator forcing you to think twice.
494914495915```typescript
496496-model Main {
497497- externalRef?: com.example.defs.Metadata;
916916+import "@typelex/emitter";
917917+918918+namespace tools.ozone.moderation.defs {
919919+ model SubjectStatusView {
920920+ subjectBlobCids?: cid[];
921921+ subjectRepoHandle?: string;
922922+923923+ @required createdAt: datetime;
924924+ }
498925}
499926```
500927501501-**Maps to:** `{"type": "ref", "ref": "com.example.defs#metadata"}`
502502-503503-Different namespace to main def (no fragment):
928928+This becomes:
504929505505-```typescript
506506-model Main {
507507- mainRef?: com.example.post.Main;
508508-}
930930+```json
931931+// ...
932932+"required": ["createdAt"]
509933```
510934511511-**Maps to:** `{"type": "ref", "ref": "com.example.post"}`
935935+## Unions
936936+937937+### Open Unions (Recommended)
512938513513-## Tokens
939939+In Lexicon, unions like "A or B" default to being *open*, i.e. allowing you to add C in the future.
514940515515-Empty models marked with `@token`:
941941+To declare an open union, write `| unknown` at the end, like `Images | Video | unknown`:
516942517943```typescript
518518-/** Indicates spam content */
519519-@token
520520-model ReasonSpam {}
944944+import "@typelex/emitter";
945945+946946+namespace app.bsky.feed.post {
947947+ model Main {
948948+ embed?: Images | Video | unknown;
949949+ }
521950522522-/** Indicates policy violation */
523523-@token
524524-model ReasonViolation {}
951951+ model Images {
952952+ // ...
953953+ }
525954526526-model Report {
527527- @required reason: (ReasonSpam | ReasonViolation | unknown);
955955+ model Video {
956956+ // ...
957957+ }
528958}
529959```
530960531531-**Maps to:**
961961+This produces a `union`:
962962+532963```json
533964{
534534- "report": {
535535- "properties": {
536536- "reason": {
537537- "type": "union",
538538- "refs": ["#reasonSpam", "#reasonViolation"]
965965+ "lexicon": 1,
966966+ "id": "app.bsky.feed.post",
967967+ "defs": {
968968+ "main": {
969969+ "type": "object",
970970+ "properties": {
971971+ "embed": {
972972+ "type": "union",
973973+ "refs": ["#images", "#video"]
974974+ }
539975 }
540540- }
541541- },
542542- "reasonSpam": {
543543- "type": "token",
544544- "description": "Indicates spam content"
976976+ },
977977+ "images": { /* ... */ },
978978+ "video": { /* ... */ }
545979 }
546980}
547981```
548982549549-## Operation Details
983983+Then later you can add more types to the union.
550984551551-### Query Parameters
985985+You may also write the same thing with the `union` syntax instead of writing `A | B | C | unknown` directly:
552986553987```typescript
554554-@query
555555-op main(
556556- @required search: string,
557557- limit?: integer = 50,
558558- tags?: string[]
559559-): { ... };
988988+namespace app.bsky.feed.post {
989989+ model Main {
990990+ embed?: EmbedType;
991991+ }
992992+993993+ @inline union EmbedType { Images, Video, unknown }
994994+995995+ model Images {
996996+ // ...
997997+ }
998998+999999+ model Video {
10001000+ // ...
10011001+ }
10021002+}
5601003```
5611004562562-Parameters can be inline with decorators before each.
10051005+This is completely equivalent. The `@inline` decorator states that it doesn't become a part of your defs.
10061006+10071007+### Known Values (Open Enums)
5631008564564-### Procedure with Input and Parameters
10091009+You can suggest common values but allow others:
56510105661011```typescript
567567-@procedure
568568-op main(
569569- input: {
570570- @required data: string;
571571- },
572572- parameters: {
573573- @required repo: atIdentifier;
574574- validate?: boolean = true;
10121012+import "@typelex/emitter";
10131013+10141014+namespace com.example {
10151015+ model Main {
10161016+ lang?: "en" | "es" | "fr" | string;
5751017 }
576576-): { ... };
10181018+}
5771019```
5781020579579-Use `input:` for body, `parameters:` for query params.
10211021+Note the `| string` that says you may add more "known" values later.
5801022581581-### No Output
10231023+The `union` syntax also works for this:
58210245831025```typescript
584584-@procedure
585585-op main(input: {
586586- @required uri: atUri;
587587-}): void;
10261026+import "@typelex/emitter";
10271027+10281028+namespace com.example {
10291029+ model Main {
10301030+ lang?: Languages;
10311031+ }
10321032+10331033+ @inline union Languages { "en", "es", "fr", string }
10341034+}
5881035```
5891036590590-Use `: void` for procedures with no output.
10371037+Here, `@inline` prevents creation of a `languages` top-level def. If you *do* want to make it reusable from other Lexicons, remove the `@inline` annotation and refer to `com.example.Languages` from somewhere else.
10381038+10391039+### Closed Unions and Enums (Discouraged)
10401040+10411041+If you want to create a *closed* union (which are **heavily discouraged** in Lexicon), you can mark your union with a `@closed` decorator. This will let you remove the `unknown` case from it.
5911042592592-### Output Without Schema
10431043+There is no shorthand notation for closed unions, so you'll have to write `@closed @inline union { A, B }`.
59310445941045```typescript
595595-@query
596596-@encoding("application/json")
597597-op main(id?: string): never;
10461046+import "@typelex/emitter";
10471047+10481048+namespace com.atproto.repo.applyWrites {
10491049+ @procedure
10501050+ op main(input: {
10511051+ @required
10521052+ writes: WriteAction[];
10531053+ }): {
10541054+ // ...
10551055+ };
10561056+10571057+ @closed // Discouraged!
10581058+ @inline
10591059+ union WriteAction { Create, Update, Delete, }
10601060+10611061+ model Create {
10621062+ // ...
10631063+ }
10641064+10651065+ model Update {
10661066+ // ...
10671067+ }
10681068+10691069+ model Delete {
10701070+ // ...
10711071+ }
10721072+}
5981073```
5991074600600-Use `: never` with `@encoding()` for output with encoding but no schema.
10751075+This gives you:
10761076+10771077+```json
10781078+"writes": {
10791079+ "type": "array",
10801080+ "items": {
10811081+ "type": "union",
10821082+ "refs": ["#create", "#update", "#delete"],
10831083+ "closed": true
10841084+ }
10851085+}
10861086+```
6011087602602-### Errors
10881088+You can also declare this with strings or numbers:
60310896041090```typescript
605605-/** The provided text is invalid */
606606-model InvalidText {}
10911091+@closed // Discouraged!
10921092+@inline
10931093+union WriteAction { "create", "update", "delete" }
10941094+```
6071095608608-/** User not found */
609609-model NotFound {}
10961096+These would become a Lexicon closed `enum`:
6101097611611-@procedure
612612-@errors(InvalidText, NotFound)
613613-op main(...): ...;
10981098+```json
10991099+"items": {
11001100+ "type": "string",
11011101+ "enum": ["create", "update", "delete"]
11021102+}
6141103```
6151104616616-Empty models with descriptions become error definitions.
11051105+Avoid closed unions and closed enums if you can.
61711066181107## Constraints
6191108···7081197model Main {
7091198 @required createdAt: datetime;
7101199 updatedAt?: datetime | null; // can be omitted or null
711711- deletedAt?: datetime; // can only be omitted
12001200+ deletedAt?: datetime; // can only be omitted
7121201}
7131202```
7141203···7211210}
7221211```
7231212724724-## Common Patterns
725725-726726-### Discriminated Unions
727727-728728-Use `@readOnly` with const for discriminator:
729729-730730-```typescript
731731-model Create {
732732- @readOnly @required type: string = "create";
733733- @required data: string;
734734-}
735735-736736-model Update {
737737- @readOnly @required type: string = "update";
738738- @required id: string;
739739-}
740740-```
741741-742742-### Nested Unions
743743-744744-```typescript
745745-model Container {
746746- @required id: string;
747747- @required payload: (PayloadA | PayloadB | unknown);
748748-}
749749-```
750750-751751-Unions can be nested in objects and arrays.
752752-7531213## Naming Conventions
75412147551215Model names convert from PascalCase to camelCase in defs:
···7591219model UserMetadata { ... } // becomes "userMetadata"
7601220model Main { ... } // becomes "main"
7611221```
762762-763763-## Decorator Style
764764-765765-- Single `@required` goes on same line: `@required text: string`
766766-- Multiple decorators go on separate lines with blank line after:
767767- ```typescript
768768- @minLength(1)
769769- @maxLength(100)
770770- text?: string;
771771- ```