An experimental TypeSpec syntax for Lexicon

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