Typelex Docs#
This maps atproto Lexicon JSON syntax to typelex (which is a TypeSpec emitter). It assumes you're familiar with Lexicon and want to understand how to express it in TypeSpec. Consult TypeSpec docs on details of TypeSpec syntax.
This page was mostly written by Claude based on the test fixtures from this repo (which are deployed in the playground). I hope it's mostly correct and comprehensible. When in doubt, refer to those fixtures.
Playground#
Go to https://playground.typelex.org/ to play with a bunch of lexicons.
Quick Start#
Every TypeSpec file starts with an import and namespace:
import "@typelex/emitter";
/** Common definitions used by other lexicons */
namespace com.example.defs {
// definitions here
}
Maps to:
{
"lexicon": 1,
"id": "com.example.defs",
"description": "Common definitions used by other lexicons",
"defs": { ... }
}
Use /** */ doc comments for descriptions (or @doc() decorator as alternative).
Top-Level Lexicon Types#
Query (XRPC Query)#
namespace com.example.getRecord {
/** Retrieve a record by ID */
@query
op main(
/** The record identifier */
@required id: string
): {
@required record: com.example.record.Main;
};
}
Maps to: {"type": "query", ...} with parameters and output
Procedure (XRPC Procedure)#
namespace com.example.createRecord {
/** Create a new record */
@procedure
op main(input: {
@required text: string;
}): {
@required uri: atUri;
@required cid: cid;
};
}
Maps to: {"type": "procedure", ...} with input and output
Subscription (XRPC Subscription)#
namespace com.example.subscribeRecords {
/** Subscribe to record updates */
@subscription
op main(cursor?: integer): (Record | Delete);
model Record {
@required uri: atUri;
@required record: com.example.record.Main;
}
model Delete {
@required uri: atUri;
}
}
Maps to: {"type": "subscription", ...} with message containing union
Record#
namespace com.example.post {
@rec("tid")
/** A post record */
model Main {
@required text: string;
@required createdAt: datetime;
}
}
Maps to: {"type": "record", "key": "tid", "record": {...}}
Record key types: @rec("tid"), @rec("any"), @rec("nsid")
Object (Plain Definition)#
namespace com.example.defs {
/** User metadata */
model Metadata {
version?: integer = 1;
tags?: string[];
}
}
Maps to: {"type": "object", "properties": {...}}
Reserved Keywords#
Use backticks for TypeScript/TypeSpec reserved words:
namespace app.bsky.feed.post.`record` { ... }
namespace `pub`.leaflet.subscription { ... }
Inline vs Definitions#
By default, models become separate defs. Use @inline to prevent this:
// Without @inline - becomes separate def "statusEnum"
union StatusEnum {
"active",
"inactive",
}
// With @inline - inlined where used
@inline
union StatusEnum {
"active",
"inactive",
}
Use @inline when you want the type directly embedded rather than referenced.
Optional vs Required Fields#
In lexicons, optional fields are the norm. Required fields are discouraged and need explicit @required:
model Post {
text?: string; // optional (common)
@required createdAt: datetime; // required (discouraged, needs decorator)
}
Maps to:
{
"type": "object",
"required": ["createdAt"],
"properties": {
"text": {"type": "string"},
"createdAt": {"type": "string", "format": "datetime"}
}
}
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 |
Unions#
Open Unions (Common Pattern)#
Open unions are the default and preferred in lexicons. Add unknown to mark as open:
model Main {
/** Can be any of these types or future additions */
@required item: TypeA | TypeB | TypeC | unknown;
}
model TypeA {
@readOnly @required kind: string = "a";
@required valueA: string;
}
Maps to:
{
"properties": {
"item": {
"type": "union",
"refs": ["#typeA", "#typeB", "#typeC"]
}
}
}
The unknown makes it open but doesn't appear in refs.
Known Values (Open String Enum)#
Suggest values but allow others:
model Main {
/** Language - suggests common values but allows any */
lang?: "en" | "es" | "fr" | string;
}
Maps to:
{
"properties": {
"lang": {
"type": "string",
"knownValues": ["en", "es", "fr"]
}
}
}
Closed Unions (Discouraged)#
⚠️ Closed unions are discouraged in lexicons as they prevent future additions. Use only when absolutely necessary:
@closed
@inline
union Action {
Create,
Update,
Delete,
}
model Main {
@required action: Action;
}
Maps to:
{
"properties": {
"action": {
"type": "union",
"refs": ["#create", "#update", "#delete"],
"closed": true
}
}
}
Closed Enums (Discouraged)#
⚠️ Closed enums are also discouraged. Use @closed @inline union for fixed value sets:
@closed
@inline
union Status {
"active",
"inactive",
"pending",
}
Maps to:
{
"type": "string",
"enum": ["active", "inactive", "pending"]
}
Integer enums work the same way:
@closed
@inline
union Fibonacci {
1, 2, 3, 5, 8,
}
Arrays#
Use [] suffix:
model Main {
/** Array of strings */
stringArray?: string[];
/** Array with size constraints */
@minItems(1)
@maxItems(10)
limitedArray?: integer[];
/** Array of references */
items?: Item[];
/** Array of union types */
mixed?: (TypeA | TypeB | unknown)[];
}
Maps to: {"type": "array", "items": {...}}
Note: @minItems/@maxItems in TypeSpec map to minLength/maxLength in JSON.
Blobs#
model Main {
/** Basic blob */
file?: Blob;
/** Image up to 5MB */
image?: Blob<#["image/*"], 5000000>;
/** Specific types up to 2MB */
photo?: Blob<#["image/png", "image/jpeg"], 2000000>;
}
Maps to:
{
"file": {"type": "blob"},
"image": {
"type": "blob",
"accept": ["image/*"],
"maxSize": 5000000
}
}
References#
Local References#
Same namespace, uses #:
model Main {
metadata?: Metadata;
}
model Metadata {
@required key: string;
}
Maps to: {"type": "ref", "ref": "#metadata"}
External References#
Different namespace to specific def:
model Main {
externalRef?: com.example.defs.Metadata;
}
Maps to: {"type": "ref", "ref": "com.example.defs#metadata"}
Different namespace to main def (no fragment):
model Main {
mainRef?: com.example.post.Main;
}
Maps to: {"type": "ref", "ref": "com.example.post"}
Tokens#
Empty models marked with @token:
/** Indicates spam content */
@token
model ReasonSpam {}
/** Indicates policy violation */
@token
model ReasonViolation {}
model Report {
@required reason: (ReasonSpam | ReasonViolation | unknown);
}
Maps to:
{
"report": {
"properties": {
"reason": {
"type": "union",
"refs": ["#reasonSpam", "#reasonViolation"]
}
}
},
"reasonSpam": {
"type": "token",
"description": "Indicates spam content"
}
}
Operation Details#
Query Parameters#
@query
op main(
@required search: string,
limit?: integer = 50,
tags?: string[]
): { ... };
Parameters can be inline with decorators before each.
Procedure with Input and Parameters#
@procedure
op main(
input: {
@required data: string;
},
parameters: {
@required repo: atIdentifier;
validate?: boolean = true;
}
): { ... };
Use input: for body, parameters: for query params.
No Output#
@procedure
op main(input: {
@required uri: atUri;
}): void;
Use : void for procedures with no output.
Output Without Schema#
@query
@encoding("application/json")
op main(id?: string): never;
Use : never with @encoding() for output with encoding but no schema.
Errors#
/** The provided text is invalid */
model InvalidText {}
/** User not found */
model NotFound {}
@procedure
@errors(InvalidText, NotFound)
op main(...): ...;
Empty models with descriptions become error definitions.
Constraints#
String Constraints#
model Main {
/** Byte length constraints */
@minLength(1)
@maxLength(100)
text?: string;
/** Grapheme cluster length constraints */
@minGraphemes(1)
@maxGraphemes(50)
displayName?: string;
}
Maps to: minLength/maxLength, minGraphemes/maxGraphemes
Integer Constraints#
model Main {
@minValue(1)
@maxValue(100)
score?: integer;
}
Maps to: minimum/maximum
Bytes Constraints#
model Main {
@minBytes(1)
@maxBytes(1024)
data?: bytes;
}
Maps to: minLength/maxLength
Note: Use @minBytes/@maxBytes in TypeSpec, but they map to minLength/maxLength in JSON.
Array Constraints#
model Main {
@minItems(1)
@maxItems(10)
items?: string[];
}
Maps to: minLength/maxLength
Note: Use @minItems/@maxItems in TypeSpec, but they map to minLength/maxLength in JSON.
Default and Constant Values#
Defaults#
model Main {
version?: integer = 1;
lang?: string = "en";
}
Maps to: {"default": 1}, {"default": "en"}
Constants#
Use @readOnly with default value:
model Main {
@readOnly status?: string = "active";
}
Maps to: {"const": "active"}
Nullable Fields#
Use | null for nullable fields:
model Main {
@required createdAt: datetime;
updatedAt?: datetime | null; // can be omitted or null
deletedAt?: datetime; // can only be omitted
}
Maps to:
{
"required": ["createdAt"],
"nullable": ["updatedAt"],
"properties": { ... }
}
Common Patterns#
Discriminated Unions#
Use @readOnly with const for discriminator:
model Create {
@readOnly @required type: string = "create";
@required data: string;
}
model Update {
@readOnly @required type: string = "update";
@required id: string;
}
Nested Unions#
model Container {
@required id: string;
@required payload: (PayloadA | PayloadB | unknown);
}
Unions can be nested in objects and arrays.
Naming Conventions#
Model names convert from PascalCase to camelCase in defs:
model StatusEnum { ... } // becomes "statusEnum"
model UserMetadata { ... } // becomes "userMetadata"
model Main { ... } // becomes "main"
Decorator Style#
- Single
@requiredgoes on same line:@required text: string - Multiple decorators go on separate lines with blank line after:
@minLength(1) @maxLength(100) text?: string;