# typelex docs Typelex is a [TypeSpec](https://typespec.io/) emitter that outputs [AT Lexicon](https://atproto.com/specs/lexicon) JSON files. ## Introduction ### What's Lexicon? [Lexicon](https://atproto.com/specs/lexicon) is a schema format used by [AT](https://atproto.com/) applications. Here's a small example: ```json { "lexicon": 1, "id": "app.bsky.bookmark.defs", "defs": { "listItemView": { "type": "object", "properties": { "uri": { "type": "string", "format": "at-uri" } }, "required": ["uri"] } } } ``` This schema is then used to generate code for parsing of these objects, their validation, and their types. ### What's TypeSpec? [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/). ### What's typelex? Typelex is a TypeSpec emitter targeting Lexicon. Here's the same schema in TypeSpec: ```typescript import "@typelex/emitter"; namespace app.bsky.bookmark.defs { model ListItemView { @required uri: atUri; } } ``` Run the compiler, and it generates Lexicon JSON for you. The JSON is what you'll publish—typelex just makes authoring easier. Think of it as "CoffeeScript for Lexicon" (however terrible that may be). 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. Personally, I find JSON Lexicons hard to read and author, so the tradeoff works for me. ### Playground [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. ## Quick Start ### Namespaces A namespace corresponds to a Lexicon file: ```typescript import "@typelex/emitter"; namespace app.bsky.feed.defs { model PostView { // ... } } ``` This emits `app/bsky/feed/defs.json`: ```json { "lexicon": 1, "id": "app.bsky.feed.defs", "defs": { ... } } ``` [Try it in the playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5mZWVkLmRlZnMgewogIG1vZGVsIFBvc3RWaWV3xRMgIHJlcGx5Q291bnQ%2FOiBpbnRlZ2VyO8gab3N01RtsaWtl0xl9Cn0K&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D). If TypeSpec complains about reserved words in namespaces, use backticks: ```typescript import "@typelex/emitter"; namespace app.bsky.feed.post.`record` { } namespace `pub`.blocks.blockquote { } ``` You can define multiple namespaces in one file: ```typescript import "@typelex/emitter"; namespace com.example.foo { model Main { /* ... */ } } namespace com.example.bar { model Main { /* ... */ } } ``` This emits two files: `com/example/foo.json` and `com/example/bar.json`. You can `import` other `.tsp` files to reuse definitions. Output structure is determined solely by namespaces, not by source file organization. ## Models By default, **every `model` becomes a Lexicon definition**: ```typescript import "@typelex/emitter"; namespace app.bsky.feed.defs { model PostView { /* ... */ } model ViewerState { /* ... */ } } ``` Model names convert PascalCase → camelCase. For example, `PostView` becomes `postView`: ```json { "id": "app.bsky.feed.defs", "defs": { "postView": { /* ... */ }, "viewerState": { /* ... */ } } // ... } ``` 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. ### Namespace Conventions: `.defs` vs `Main` By convention, a namespace must either end with `.defs` or have a `Main` model. Use `.defs` for a grabbag of reusable definitions: ```typescript import "@typelex/emitter"; namespace app.bsky.feed.defs { model PostView { /* ... */ } model ViewerState { /* ... */ } } ``` For a Lexicon about one main concept, add a `Main` model instead: ```typescript import "@typelex/emitter"; namespace app.bsky.embed.video { model Main { /* ... */ } model Caption { /* ... */ } } ``` Pick one or the other—the compiler will error if you don't. ### References Models can reference other models: ```typescript import "@typelex/emitter"; namespace app.bsky.embed.video { model Main { captions?: Caption[]; } model Caption { /* ... */ } } ``` This becomes a `ref` to the `caption` definition in the same file: ```json // ... "defs": { "main": { // ... "properties": { "captions": { // ... "items": { "type": "ref", "ref": "#caption" } } } }, "caption": { "type": "object", "properties": {} } // ... ``` You can also reference models from other namespaces: ```typescript import "@typelex/emitter"; namespace app.bsky.actor.profile { model Main { labels?: (com.atproto.label.defs.SelfLabels | unknown); } } namespace com.atproto.label.defs { model SelfLabels { /* ... */ } } ``` This becomes a fully qualified reference to another Lexicon: ```json // ... "labels": { "type": "union", "refs": ["com.atproto.label.defs#selfLabels"] } // ... ``` ([See it in the Playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5hY3Rvci5wcm9maWxlIHsKICBAcmVjKCJsaXRlcmFsOnNlbGYiKQogIG1vZGVsIE1haW7FJiAgZGlzcGxheU5hbWU%2FOiBzdHJpbmc7xRpsYWJlbHM%2FOiAoY29tLmF0cHJvdG8uxRYuZGVmcy5TZWxmTMUlIHwgdW5rbm93binEPH0KfewAptY%2F5QCA5gCPy0nEFSAgLy8gLi4uxko%3D&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D).) This works across files too—just remember to `import` the file with the definition. ### External Stubs If you don't have TypeSpec definitions for external Lexicons, you can stub them out using the `@external` decorator: ```typescript import "@typelex/emitter"; namespace app.bsky.actor.profile { model Main { labels?: (com.atproto.label.defs.SelfLabels | unknown); } } // Empty stub (like .d.ts in TypeScript) @external namespace com.atproto.label.defs { model SelfLabels { } } ``` 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. 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. You'll want to ensure the real JSON for external Lexicons is available before running codegen. ### Inline Models By default, every `model` becomes a top-level def: ```typescript import "@typelex/emitter"; namespace app.bsky.embed.video { model Main { captions?: Caption[]; } model Caption { /* ... */ } } ``` This creates two defs: `main` and `caption`. Use `@inline` to expand a model inline instead: ```typescript import "@typelex/emitter"; namespace app.bsky.embed.video { model Main { captions?: Caption[]; } @inline model Caption { text?: string } } ``` Now `Caption` is expanded inline: ```json // ... "captions": { "type": "array", "items": { "type": "object", "properties": { "text": { "type": "string" } } } } // ... ``` Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. ### Scalars TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models): ```typescript import "@typelex/emitter"; namespace com.example { model Main { handle?: Handle; bio?: Bio; } @maxLength(50) scalar Handle extends string; @maxLength(256) @maxGraphemes(128) scalar Bio extends string; } ``` This creates three defs: `main`, `handle`, and `bio`: ```json { "id": "com.example", "defs": { "main": { "type": "object", "properties": { "handle": { "type": "ref", "ref": "#handle" }, "bio": { "type": "ref", "ref": "#bio" } } }, "handle": { "type": "string", "maxLength": 50 }, "bio": { "type": "string", "maxLength": 256, "maxGraphemes": 128 } } } ``` Use `@inline` to expand a scalar inline instead: ```typescript import "@typelex/emitter"; namespace com.example { model Main { handle?: Handle; } @inline @maxLength(50) scalar Handle extends string; } ``` Now `Handle` is expanded inline (no separate def): ```json // ... "properties": { "handle": { "type": "string", "maxLength": 50 } } // ... ``` ## Top-Level Lexicon Types TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. ### Objects A plain `model` becomes a Lexicon object: ```typescript import "@typelex/emitter"; namespace com.example.post { model Main { /* ... */ } } ``` Output: ```json // ... "main": { "type": "object", "properties": { /* ... */ } } // ... ``` ### Records Use `@rec` to make a model a Lexicon record: ```typescript import "@typelex/emitter"; namespace com.example.post { @rec("tid") model Main { /* ... */ } } ``` Output: ```json // ... "main": { "type": "record", "key": "tid", "record": { "type": "object", "properties": { /* ... */ } } } // ... ``` You can pass any [Record Key Type](https://atproto.com/specs/record-key): `@rec("tid")`, `@rec("nsid")`, `@rec("literal:self")`, etc. (It's `@rec` not `@record` because "record" is reserved in TypeSpec.) ### Queries In TypeSpec, use [`op`](https://typespec.io/docs/language-basics/operations/) for functions. Mark with `@query` for queries: ```typescript import "@typelex/emitter"; namespace com.atproto.repo.getRecord { @query op main( @required repo: atIdentifier, @required collection: nsid, @required rkey: recordKey, cid?: cid ): { @required uri: atUri; cid?: cid; }; } ``` Arguments become `parameters`, return type becomes `output`: ```json // ... "main": { "type": "query", "parameters": { "type": "params", "properties": { "repo": { /* ... */ }, "collection": { /* ... */ }, // ... }, "required": ["repo", "collection", "rkey"] }, "output": { "encoding": "application/json", "schema": { "type": "object", "properties": { "uri": { /* ... */ }, "cid": { /* ... */ } }, "required": ["uri"] } } } // ... ``` `encoding` defaults to `"application/json"`. Override with `@encoding("foo/bar")` on the `op`. Declare errors with `@errors`: ```typescript import "@typelex/emitter"; namespace com.atproto.repo.getRecord { @query @errors(FooError, BarError) op main(/* ... */): { /* ... */ }; model FooError {} model BarError {} } ``` You can extract the `{ /* ... */ }` output type to a separate `@inline` model if you prefer. ### Procedures Use `@procedure` for procedures. The first argument must be called `input`: ```typescript import "@typelex/emitter"; namespace com.example.createRecord { @procedure op main(input: { @required text: string; }): { @required uri: atUri; @required cid: cid; }; } ``` Output: ```json // ... "main": { "type": "procedure", "input": { "encoding": "application/json", "schema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } }, "output": { "encoding": "application/json", "schema": { "type": "object", "properties": { "uri": { /* ... */ }, "cid": { /* ... */ } }, "required": ["uri", "cid"] } } } // ... ``` Procedures can also receive parameters (rarely used): `op main(input: {}, parameters: {})`. Use `: void` for no output, or `: never` with `@encoding()` on the `op` for output with encoding but no schema. ### Subscriptions Use `@subscription` for subscriptions: ```typescript import "@typelex/emitter"; namespace com.atproto.sync.subscribeRepos { @subscription @errors(FutureCursor, ConsumerTooSlow) op main(cursor?: integer): Commit | Sync | unknown; model Commit { /* ... */ } model Sync { /* ... */ } model FutureCursor {} model ConsumerTooSlow {} } ``` Output: ```json // ... "main": { "type": "subscription", "parameters": { "type": "params", "properties": { "cursor": { /* ... */ } } }, "message": { "schema": { "type": "union", "refs": ["#commit", "#sync"] } }, "errors": [{ "name": "FutureCursor" }, { "name": "ConsumerTooSlow" }] } // ... ``` ### Tokens Use `@token` for empty token models: ```typescript namespace com.example.moderation.defs { @token model ReasonSpam {} @token model ReasonViolation {} model Report { @required reason: (ReasonSpam | ReasonViolation | unknown); } } ``` Output: ```json // ... "reasonSpam": { "type": "token" }, "reasonViolation": { "type": "token" }, "report": { "type": "object", "properties": { "reason": { "type": "union", "refs": ["#reasonSpam", "#reasonViolation"] } }, "required": ["reason"] } // ... ``` ## Data Types All [Lexicon data types](https://atproto.com/specs/lexicon#overview-of-types) are supported. ### Primitive Types | TypeSpec | Lexicon JSON | |----------|--------------| | `boolean` | `{"type": "boolean"}` | | `integer` | `{"type": "integer"}` | | `string` | `{"type": "string"}` | | `bytes` | `{"type": "bytes"}` | | `cidLink` | `{"type": "cid-link"}` | | `unknown` | `{"type": "unknown"}` | ### Format Types Specialized string formats: | TypeSpec | Lexicon Format | |----------|----------------| | `atIdentifier` | `at-identifier` - Handle or DID | | `atUri` | `at-uri` - AT Protocol URI | | `cid` | `cid` - Content ID | | `datetime` | `datetime` - ISO 8601 datetime | | `did` | `did` - DID identifier | | `handle` | `handle` - Handle identifier | | `nsid` | `nsid` - Namespaced ID | | `tid` | `tid` - Timestamp ID | | `recordKey` | `record-key` - Record key | | `uri` | `uri` - Generic URI | | `language` | `language` - Language tag | ### Arrays Use `[]` suffix: ```typescript import "@typelex/emitter"; namespace com.example.arrays { model Main { stringArray?: string[]; @minItems(1) @maxItems(10) limitedArray?: integer[]; items?: Item[]; mixed?: (TypeA | TypeB | unknown)[]; } // ... } ``` Output: `{ "type": "array", "items": {...} }`. Note: `@minItems`/`@maxItems` map to `minLength`/`maxLength` in JSON. ### Blobs ```typescript import "@typelex/emitter"; namespace com.example.blobs { model Main { file?: Blob; image?: Blob<#["image/*"], 5000000>; photo?: Blob<#["image/png", "image/jpeg"], 2000000>; } } ``` Output: ```json // ... "image": { "type": "blob", "accept": ["image/*"], "maxSize": 5000000 } // ... ``` ## Required and Optional Fields In Lexicon, fields are optional by default. Use `?:`: ```typescript import "@typelex/emitter"; namespace tools.ozone.moderation.defs { model SubjectStatusView { subjectRepoHandle?: string; } } ``` **Think thrice before adding required fields**—you can't make them optional later. This is why `@required` is explicit: ```typescript import "@typelex/emitter"; namespace tools.ozone.moderation.defs { model SubjectStatusView { subjectRepoHandle?: string; @required createdAt: datetime; } } ``` Output: ```json // ... "required": ["createdAt"] // ... ``` ## Unions ### Open Unions (Recommended) Unions default to being *open*—allowing you to add more options later. Write `| unknown`: ```typescript import "@typelex/emitter"; namespace app.bsky.feed.post { model Main { embed?: Images | Video | unknown; } model Images { /* ... */ } model Video { /* ... */ } } ``` Output: ```json // ... "embed": { "type": "union", "refs": ["#images", "#video"] } // ... ``` You can also use the `union` syntax to give it a name: ```typescript import "@typelex/emitter"; namespace app.bsky.feed.post { model Main { embed?: EmbedType; } @inline union EmbedType { Images, Video, unknown } model Images { /* ... */ } model Video { /* ... */ } } ``` The `@inline` prevents it from becoming a separate def in the output. ### Known Values (Open Enums) Suggest common values but allow others with `| string`: ```typescript import "@typelex/emitter"; namespace com.example { model Main { lang?: "en" | "es" | "fr" | string; } } ``` The `union` syntax works here too: ```typescript import "@typelex/emitter"; namespace com.example { model Main { lang?: Languages; } @inline union Languages { "en", "es", "fr", string } } ``` You can remove `@inline` to make it a reusable `def` accessible from other Lexicons. ### Closed Unions and Enums (Discouraged) **Heavily discouraged** in Lexicon. Marking a `union` as `@closed` lets you remove `unknown` from the list of options: ```typescript import "@typelex/emitter"; namespace com.atproto.repo.applyWrites { model Main { @required writes: WriteAction[]; } @closed // Discouraged! @inline union WriteAction { Create, Update, Delete } model Create { /* ... */ } model Update { /* ... */ } model Delete { /* ... */ } } ``` Output: ```json // ... "writes": { "type": "array", "items": { "type": "union", "refs": ["#create", "#update", "#delete"], "closed": true } } // ... ``` With strings or numbers, this becomes a closed `enum`: ```typescript import "@typelex/emitter"; namespace com.atproto.repo.applyWrites { model Main { @required action: WriteAction; } @closed // Discouraged! @inline union WriteAction { "create", "update", "delete" } } ``` Output: ```json // ... "type": "string", "enum": ["create", "update", "delete"] // ... ``` Avoid closed unions/enums when possible. ## Constraints ### Strings ```typescript import "@typelex/emitter"; namespace com.example { model Main { @minLength(1) @maxLength(100) text?: string; @minGraphemes(1) @maxGraphemes(50) displayName?: string; } } ``` Maps to: `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes` ### Integers ```typescript import "@typelex/emitter"; namespace com.example { model Main { @minValue(1) @maxValue(100) score?: integer; } } ``` Maps to: `minimum`/`maximum` ### Bytes ```typescript import "@typelex/emitter"; namespace com.example { model Main { @minBytes(1) @maxBytes(1024) data?: bytes; } } ``` Maps to: `minLength`/`maxLength` ### Arrays ```typescript import "@typelex/emitter"; namespace com.example { model Main { @minItems(1) @maxItems(10) items?: string[]; } } ``` Maps to: `minLength`/`maxLength` ## Defaults and Constants ### Property Defaults You can set default values on properties: ```typescript import "@typelex/emitter"; namespace com.example { model Main { version?: integer = 1; lang?: string = "en"; } } ``` Maps to: `{"default": 1}`, `{"default": "en"}` ### Type Defaults You can also set defaults on scalar and union types using the `@default` decorator: ```typescript import "@typelex/emitter"; namespace com.example { model Main { mode?: Mode; priority?: Priority; } @default("standard") scalar Mode extends string; @default(1) @closed @inline union Priority { 1, 2, 3 } } ``` This creates a default on the type definition itself: ```json { "defs": { "mode": { "type": "string", "default": "standard" } } } ``` For unions with token references, pass the model directly: ```typescript import "@typelex/emitter"; namespace com.example { model Main { eventType?: EventType; } @default(InPerson) union EventType { Hybrid, InPerson, Virtual, string } @token model Hybrid {} @token model InPerson {} @token model Virtual {} } ``` This resolves to the fully-qualified token NSID: ```json { "eventType": { "type": "string", "knownValues": [ "com.example#hybrid", "com.example#inPerson", "com.example#virtual" ], "default": "com.example#inPerson" } } ``` **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: ```typescript @default("standard") scalar Mode extends string; model Main { mode?: Mode = "custom"; // ERROR: Conflicting defaults! } ``` Solutions: 1. Make the defaults match: `mode?: Mode = "standard"` 2. Mark the type `@inline`: Allows property-level defaults 3. Remove the property default: Uses the type's default ### Constants Use `@readOnly` with a default: ```typescript import "@typelex/emitter"; namespace com.example { model Main { @readOnly status?: string = "active"; } } ``` Maps to: `{"const": "active"}` ## Nullable Fields Use `| null` for nullable fields: ```typescript import "@typelex/emitter"; namespace com.example { model Main { @required createdAt: datetime; updatedAt?: datetime | null; // can be omitted or null deletedAt?: datetime; // can only be omitted } } ``` Output: ```json // ... "required": ["createdAt"], "nullable": ["updatedAt"] // ... ```