An experimental TypeSpec syntax for Lexicon

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 @required goes on same line: @required text: string
  • Multiple decorators go on separate lines with blank line after:
    @minLength(1)
    @maxLength(100)
    text?: string;