An experimental TypeSpec syntax for Lexicon

typelex docs#

Typelex is a TypeSpec emitter that outputs AT Lexicon JSON files.

Introduction#

What's Lexicon?#

Lexicon is a schema format used by AT applications. Here's a small example:

{
  "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 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 and a Protobuf emitter.

What's typelex?#

Typelex is a TypeSpec emitter targeting Lexicon. Here's the same schema in TypeSpec:

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 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 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:

import "@typelex/emitter";

namespace app.bsky.feed.defs {
  model PostView {
    // ...
  }
}

This emits app/bsky/feed/defs.json:

{
  "lexicon": 1,
  "id": "app.bsky.feed.defs",
  "defs": { ... }
}

Try it in the playground.

If TypeSpec complains about reserved words in namespaces, use backticks:

import "@typelex/emitter";

namespace app.bsky.feed.post.`record` { }
namespace `pub`.blocks.blockquote { }

You can define multiple namespaces in one file:

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:

import "@typelex/emitter";

namespace app.bsky.feed.defs {
  model PostView { /* ... */ }
  model ViewerState { /* ... */ }
}

Model names convert PascalCase → camelCase. For example, PostView becomes postView:

{
  "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). 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:

import "@typelex/emitter";

namespace app.bsky.feed.defs {
  model PostView { /* ... */ }
  model ViewerState { /* ... */ }
}

For a Lexicon about one main concept, add a Main model instead:

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:

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:

// ...
"defs": {
  "main": {
    // ...
    "properties": {
      "captions": {
        // ...
        "items": { "type": "ref", "ref": "#caption" }
      }
    }
  },
  "caption": {
    "type": "object",
    "properties": {}
  }
// ...

You can also reference models from other namespaces:

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:

// ...
"labels": {
  "type": "union",
  "refs": ["com.atproto.label.defs#selfLabels"]
}
// ...

(See it in the Playground.)

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:

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:

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:

import "@typelex/emitter";

namespace app.bsky.embed.video {
  model Main {
    captions?: Caption[];
  }

  @inline
  model Caption {
    text?: string
  }
}

Now Caption is expanded inline:

// ...
"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):

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:

{
  "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:

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):

// ...
"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:

import "@typelex/emitter";

namespace com.example.post {
  model Main { /* ... */ }
}

Output:

// ...
"main": {
  "type": "object",
  "properties": { /* ... */ }
}
// ...

Records#

Use @rec to make a model a Lexicon record:

import "@typelex/emitter";

namespace com.example.post {
  @rec("tid")
  model Main { /* ... */ }
}

Output:

// ...
"main": {
  "type": "record",
  "key": "tid",
  "record": { "type": "object", "properties": { /* ... */ } }
}
// ...

You can pass any Record Key Type: @rec("tid"), @rec("nsid"), @rec("literal:self"), etc.

(It's @rec not @record because "record" is reserved in TypeSpec.)

Queries#

In TypeSpec, use op for functions. Mark with @query for queries:

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:

// ...
"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:

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:

import "@typelex/emitter";

namespace com.example.createRecord {
  @procedure
  op main(input: {
    @required text: string;
  }): {
    @required uri: atUri;
    @required cid: cid;
  };
}

Output:

// ...
"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:

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:

// ...
"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:

namespace com.example.moderation.defs {
  @token
  model ReasonSpam {}

  @token
  model ReasonViolation {}

  model Report {
    @required reason: (ReasonSpam | ReasonViolation | unknown);
  }
}

Output:

// ...
"reasonSpam": { "type": "token" },
"reasonViolation": { "type": "token" },
"report": {
  "type": "object",
  "properties": {
    "reason": {
      "type": "union",
      "refs": ["#reasonSpam", "#reasonViolation"]
    }
  },
  "required": ["reason"]
}
// ...

Data Types#

All Lexicon data 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:

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#

import "@typelex/emitter";

namespace com.example.blobs {
  model Main {
    file?: Blob;
    image?: Blob<#["image/*"], 5000000>;
    photo?: Blob<#["image/png", "image/jpeg"], 2000000>;
  }
}

Output:

// ...
"image": {
  "type": "blob",
  "accept": ["image/*"],
  "maxSize": 5000000
}
// ...

Required and Optional Fields#

In Lexicon, fields are optional by default. Use ?::

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:

import "@typelex/emitter";

namespace tools.ozone.moderation.defs {
  model SubjectStatusView {
    subjectRepoHandle?: string;
    @required createdAt: datetime;
  }
}

Output:

// ...
"required": ["createdAt"]
// ...

Unions#

Unions default to being open—allowing you to add more options later. Write | unknown:

import "@typelex/emitter";

namespace app.bsky.feed.post {
  model Main {
    embed?: Images | Video | unknown;
  }

  model Images { /* ... */ }
  model Video { /* ... */ }
}

Output:

// ...
"embed": {
  "type": "union",
  "refs": ["#images", "#video"]
}
// ...

You can also use the union syntax to give it a name:

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:

import "@typelex/emitter";

namespace com.example {
  model Main {
    lang?: "en" | "es" | "fr" | string;
  }
}

The union syntax works here too:

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:

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:

// ...
"writes": {
  "type": "array",
  "items": {
    "type": "union",
    "refs": ["#create", "#update", "#delete"],
    "closed": true
  }
}
// ...

With strings or numbers, this becomes a closed enum:

import "@typelex/emitter";

namespace com.atproto.repo.applyWrites {
  model Main {
    @required action: WriteAction;
  }

  @closed // Discouraged!
  @inline
  union WriteAction { "create", "update", "delete" }
}

Output:

// ...
"type": "string",
"enum": ["create", "update", "delete"]
// ...

Avoid closed unions/enums when possible.

Constraints#

Strings#

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#

import "@typelex/emitter";

namespace com.example {
  model Main {
    @minValue(1)
    @maxValue(100)
    score?: integer;
  }
}

Maps to: minimum/maximum

Bytes#

import "@typelex/emitter";

namespace com.example {
  model Main {
    @minBytes(1)
    @maxBytes(1024)
    data?: bytes;
  }
}

Maps to: minLength/maxLength

Arrays#

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:

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:

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:

{
  "defs": {
    "mode": {
      "type": "string",
      "default": "standard"
    }
  }
}

For unions with token references, pass the model directly:

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:

{
  "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:

@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:

import "@typelex/emitter";

namespace com.example {
  model Main {
    @readOnly status?: string = "active";
  }
}

Maps to: {"const": "active"}

Nullable Fields#

Use | null for nullable fields:

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:

// ...
"required": ["createdAt"],
"nullable": ["updatedAt"]
// ...