typelex docs#
typelex is a TypeSpec emitter targeting atproto Lexicon JSON as the output format.
This page 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.
What is this?#
It's just a weekend experiment. Paul said I can give it a try so I figured why not.
Playground#
Go to https://playground.typelex.org/ to play with a bunch of lexicons.
If you already know Lexicon, you can learn the syntax by just opening the Lexicons you're familiar with.
Namespaces#
Every TypeSpec file starts with an import and namespace:
import "@typelex/emitter";
/** Some docstring */
namespace com.example.defs {
// definitions here
}
Maps to:
{
"lexicon": 1,
"id": "com.example.defs",
"description": "Some docstring",
"defs": { ... }
}
Use /** */ doc comments for descriptions (or @doc() decorator as alternative).
Use backticks for TypeScript/TypeSpec reserved words:
namespace app.bsky.feed.post.`record` {
...
}
The first namespace in the file will be turned into JSON. However, you can put more of them in the same file (if you want to reference them) or import other .tsp files from it.
(Currently, we're assuming you write everything in typelex. You can grab common Lexicons from the Playground). It should not be hard to also stub out dependencies with namespaces + empty defs, or to add special syntax for a ref (which isn't availeble to resolve). For now, I haven't done that. This needs more thought.)
Top-Level Lexicon Types#
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": {...}}
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
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;