An experimental TypeSpec syntax for Lexicon

wip

+789 -339
+789 -339
DOCS.md
··· 1 1 # typelex docs 2 2 3 - typelex is a [TypeSpec](https://typespec.io/) emitter targeting [atproto Lexicon](https://atproto.com/specs/lexicon) JSON as the output format. 3 + Typelex is a [TypeSpec](https://typespec.io/) emitter that outputs [AT Lexicon](https://atproto.com/specs/lexicon) JSON files. 4 + 5 + ## Introduction 6 + 7 + ### What's TypeSpec? 8 + 9 + [TypeSpec](https://typespec.io/) is a TypeScript-like language for writing schemas for data and API calls. 10 + 11 + TypeSpec offers flexible syntax for describing schemas, as well as the tooling for it (like LSP), but it doesn't specify the semantics or the output format. It can target different output formats via *emitters*. For example, there's a [JSON Schema emitter](https://typespec.io/docs/emitters/json-schema/reference/) and a [Protobuf emitter](https://typespec.io/docs/emitters/protobuf/reference/) for TypeSpec. Emitters determine the output format. Emitters can define or restrict the available built-in types. Emitters can also define their own *decorators* that attach to different pieces of syntax. 12 + 13 + ### What's Lexicon? 14 + 15 + [Lexicon](https://atproto.com/specs/lexicon) is a schema format used by [AT](https://atproto.com/) applications. 16 + 17 + Here is a small Lexicon schema defining an `app.bsky.bookmark.defs` Lexicon containing a `listItemView` definition, which describes an `object` with a required `uri` property of type `at-uri`: 18 + 19 + ```json 20 + { 21 + "lexicon": 1, 22 + "id": "app.bsky.bookmark.defs", 23 + "defs": { 24 + "listItemView": { 25 + "type": "object", 26 + "properties": { 27 + "uri": { "type": "string", "format": "at-uri" } 28 + }, 29 + "required": ["uri"] 30 + } 31 + } 32 + } 33 + ``` 34 + 35 + You would then generate code from this schema that takes care of parsing and validating a piece of data of that shape, as well as the type definitions (e.g. for Go or TypeScript). 36 + 37 + ### What's typelex? 38 + 39 + Typelex is a TypeSpec emitter that targets AT Lexicon as the output format. As such, it adds built-in Lexicon data types and a few decorators to control the structure of the Lexicon output. 40 + 41 + Here's the above Lexicon written in TypeSpec (with the typelex emitter): 42 + 43 + ```typescript 44 + import "@typelex/emitter"; 4 45 5 - This page assumes you're familiar with Lexicon and want to understand how to express it in TypeSpec. Consult [TypeSpec docs](https://typespec.io/) on details of TypeSpec syntax. 46 + namespace app.bsky.bookmark.defs { 47 + model ListItemView { 48 + @required uri: atUri; 49 + } 50 + } 51 + ``` 52 + 53 + Then you run the compiler, and it generates the Lexicon JSON for you. 54 + 55 + It is important to note that the JSON format is in which you'll publish your Lexicons. Typelex only exists for authoring convenience and does not aim to supplant or replace Lexicon JSON itself. Think of it as a "CoffeeScript for Lexicon" (however terrible that may be). 6 56 7 - This page was mostly written by Claude based on the test fixtures from this repo (which are [deployed in the playground](https://playground.typelex.org/)). I hope it's mostly correct and comprehensible. When in doubt, refer to those fixtures. 57 + Typelex tries to stay faithful to both TypeSpec idioms and Lexicon concepts, which makes it a bit confusing and sometimes not elegant. Since we can't add new keywords to TypeSpec, often there's a decorator that fills the need. For example, you'll have to write `@procedure op` whereas ideally there would just be a `procedure` keyword, or you'll have to write `model` for something that Lexicon calls a "def". In essence, you have to learn both Lexicon *and* TypeSpec. It is a good idea to scan through the [TypeSpec language overview](https://typespec.io/docs/language-basics/overview/) to get a feel for it. 8 58 9 - ## What is this? 59 + Personally, I find JSON Lexicons difficult to read and to author so the tradeoff is worth it for me. 10 60 11 - It's just a weekend experiment. Paul said [I can give it a try](https://bsky.app/profile/pfrazee.com/post/3m2asobpr422b) so I figured [why not](https://underreacted.leaflet.pub/3m23gqakbqs2j). 61 + ### Playground 12 62 13 - ## Playground 63 + [Open Playground](https://playground.typelex.org/) 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. 14 64 15 - [Open Playground](https://playground.typelex.org/) to play with a bunch of lexicons. 65 + ## Quick Start 16 66 17 - If you already know Lexicon, you can learn the syntax by just opening the Lexicons you're familiar with. 67 + Let's dive into some common patterns and see how to write them. 18 68 19 - ## Namespaces 69 + ### Namespaces 20 70 21 - Every TypeSpec file starts with an import and namespace: 71 + A namespace corresponds to a Lexicon output file. For example: 22 72 23 73 ```typescript 24 74 import "@typelex/emitter"; 25 75 26 - /** Some docstring */ 27 - namespace com.example.defs { 28 - // definitions here 76 + namespace app.bsky.feed.defs { 77 + model PostView { 78 + // ... 79 + } 29 80 } 30 81 ``` 31 82 32 - **Maps to:** 83 + will emit `app/bsky/feed/defs.json`: 84 + 33 85 ```json 34 86 { 35 87 "lexicon": 1, 36 - "id": "com.example.defs", 37 - "description": "Some docstring", 38 - "defs": { ... } 88 + "id": "app.bsky.feed.defs", 89 + // ... 39 90 } 40 91 ``` 41 92 42 - Use `/** */` doc comments for descriptions (or `@doc()` decorator as alternative). 93 + You can [try it in the playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5mZWVkLmRlZnMgewogIG1vZGVsIFBvc3RWaWV3xRMgIHJlcGx5Q291bnQ%2FOiBpbnRlZ2VyO8gab3N01RtsaWtl0xl9Cn0K&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D). 43 94 44 - ## Reserved Words 95 + ### Reserved Words 45 96 46 97 Use backticks for TypeScript/TypeSpec reserved words: 47 98 48 99 ```typescript 49 - namespace app.bsky.feed.post.`record` { 50 - ... 51 - } 100 + namespace app.bsky.feed.post.`record` { } 101 + 102 + namespace `pub`.blocks.blockquote { } 52 103 ``` 53 104 54 - ## Many Lexicons in One File 105 + ### Multiple Namespaces in One File 55 106 56 107 Multiple namespaces can be defined in one file: 57 108 ··· 61 112 namespace com.example.foo { 62 113 @rec("tid") 63 114 model Main { 64 - bar?: com.example.bar.Main 115 + bar?: com.example.bar.Main 65 116 } 66 117 } 67 118 ··· 73 124 } 74 125 ``` 75 126 76 - This single `.tsp` file will turn into two Lexicon files in the output folder: 127 + This single `.tsp` file will emit two Lexicon files: 77 128 78 129 ``` 79 130 - com/example/foo.json 80 131 - com/example/bar.json 81 132 ``` 82 133 83 - You can see this in action in the Playground, where every example is written as a single input file. For example, [this `app.bsky.actor.profile` example](https://playground.typelex.org/?sample=app.bsky.actor.profile) "spits out" multiple outputs like `app/bsky/actor/profile.json`, `com/atproto/label/defs.json`, and so on, which you can see in the right pane. 84 - 85 - ## Dependencies 134 + You can `import` other `.tsp` files to avoid defining the same thing multiple times. The output structure is only determined by namespaces, not by how you split your input files. 86 135 87 - You can `import` other `.tsp` files. This lets you avoid defining the same thing many times if you want to refer to it from many files. 136 + ## Models 88 137 89 - For example: 138 + A namespace may contain [`model`s](https://typespec.io/docs/language-basics/models/) inside. By default, **every `model` turns into a Lexicon definition** (`defs` in the Lexicon output). For example, if you have a `PostView` model defined like this: 90 139 91 140 ```typescript 92 - // main.tsp 93 - 94 141 import "@typelex/emitter"; 95 - import "./bar.tsp"; 96 142 97 - namespace com.example.foo { 98 - @rec("tid") 99 - model Main { 100 - bar?: com.example.bar.Main 143 + namespace app.bsky.feed.defs { 144 + model PostView { 145 + // ... 146 + } 147 + } 148 + ``` 149 + 150 + It will then become `postView` definition in the `defs` of this namespace's Lexicon: 151 + 152 + ```json 153 + { 154 + "lexicon": 1, 155 + "id": "app.bsky.feed.defs", 156 + "defs": { 157 + "postView": { 158 + // ... 159 + } 101 160 } 102 161 } 103 - ```` 162 + ``` 163 + 164 + A namespace may have multiple `model`s: 104 165 105 166 ```typescript 106 - // bar.tsp 107 - namespace com.example.bar { 108 - model Main { 109 - @maxGraphemes(1) 110 - bla?: string; 167 + namespace app.bsky.feed.defs { 168 + model PostView { 169 + // ... 170 + } 171 + 172 + model ViewerState { 173 + // ... 111 174 } 112 175 } 113 - ```` 176 + ``` 114 177 115 - It doesn't matter how you split input into files. The output structure is only determined by namespaces. 178 + Every `model` becomes an entry in `defs`: 116 179 117 - ## Stubs 180 + ```json 181 + { 182 + "lexicon": 1, 183 + "id": "app.bsky.feed.defs", 184 + "defs": { 185 + "postView": { 186 + // ... 187 + }, 188 + "viewerState": { 189 + // .. 190 + } 191 + } 192 + } 193 + ``` 118 194 119 - For now, we're assuming you write everything in TypeSpec. You can grab common Lexicons from the [Playground](https://playground.typelex.org/). However, if you just want a ref, you could stub out any external Lexicon, like this: 195 + This works even if they're declared in separate `namespace` blocks: 120 196 121 197 ```typescript 122 - namespace com.example.defs { 123 - subject: com.atproto.repo.strongRef.Main; // OK to use here 198 + namespace app.bsky.feed.defs { 199 + model PostView { 200 + // ... 201 + } 124 202 } 125 203 126 - // This is a stub! 127 - namespace com.atproto.repo.strongRef { 128 - model Main { } // It's fine to leave this empty 204 + namespace app.bsky.feed.defs { 205 + model ViewerState { 206 + // ... 207 + } 129 208 } 130 209 ``` 131 210 132 - Maybe this should be more ergonomic. I haven't tried this in a real project so I don't know what workflows are good. 211 + These blocks could even be in different files, one [`import`ing](https://typespec.io/docs/language-basics/imports/) the other. 133 212 134 - ## Top-Level Lexicon Types 213 + In either case, TypeSpec will find all the models inside each namespace (such as `app.bsky.feed.defs`), and bundle each namespace into a separate Lexicon file (such as `app/bsky/feed/defs.json`) with all its `defs`. 135 214 136 - In TypeSpec, definitions of things are called [Models](https://typespec.io/docs/language-basics/models/). 215 + ### Namespace Conventions: `.defs` vs `Main` 137 216 138 - So you'll see `model Foo { }` used for almost everything, but with different decorators that make its purpose more conrete. 217 + By Lexicon convention, a namespace must either have a `Main` model or end with `.defs`. 139 218 140 - ### Record 219 + You should end your namespace in `.defs` if you want a grabbag of reusable definitions: 141 220 142 221 ```typescript 143 - namespace com.example.post { 144 - @rec("tid") 145 - model Main { 146 - @required text: string; 147 - @required createdAt: datetime; 222 + namespace app.bsky.feed.defs { 223 + model PostView { 224 + // ... 225 + } 226 + 227 + model ViewerState { 228 + // ... 148 229 } 149 230 } 150 231 ``` 151 232 152 - Note it's `@rec` because (unfortunately) `record` is reserved in TypeSpec. 233 + On the other hand, if your Lexicon is about one main concept, don't add `.defs` to the namespace, and instead pick some model to be called `Main`: 153 234 154 - **Maps to:** `{"type": "record", "key": "tid", "record": {...}}` 235 + ```typescript 236 + namespace app.bsky.embed.video { 237 + model Main { 238 + @required 239 + video: Blob<#["video/mp4"], 100000000>; 155 240 156 - **Record key types:** `@rec("tid")`, `@rec("any")`, `@rec("nsid")` 241 + @maxItems(20) 242 + captions?: Caption[]; 243 + } 157 244 158 - ### Object (Plain Definition) 159 - 160 - ```typescript 161 - namespace com.example.defs { 162 - /** User metadata */ 163 - model Metadata { 164 - version?: integer = 1; 165 - tags?: string[]; 245 + model Caption { 246 + // ... 166 247 } 167 248 } 168 249 ``` 169 250 170 - **Maps to:** `{"type": "object", "properties": {...}}` 251 + Either do one or the other, or you'll get a compile error forcing you to choose. 252 + 253 + ### References 171 254 172 - ### Query (XRPC Query) 255 + A `model` can reference another `model` as a data type like so: 173 256 174 257 ```typescript 175 - namespace com.example.getRecord { 176 - /** Retrieve a record by ID */ 177 - @query 178 - op main( 179 - /** The record identifier */ 180 - @required id: string 181 - ): { 182 - @required record: com.example.record.Main; 183 - }; 258 + namespace app.bsky.embed.video { 259 + model Main { 260 + // A reference to the `Caption` model below: 261 + captions?: Caption[]; 262 + } 263 + 264 + model Caption { 265 + // ... 266 + } 184 267 } 185 268 ``` 186 269 187 - **Maps to:** `{"type": "query", ...}` with `parameters` and `output` 270 + In the resulting Lexicon JSON, this becomes a `ref` to the local `#caption` def: 271 + 272 + ```json 273 + { 274 + "lexicon": 1, 275 + "id": "app.bsky.embed.video", 276 + "defs": { 277 + "main": { 278 + "type": "object", 279 + "properties": { 280 + "captions": { 281 + "type": "array", 282 + "items": { 283 + // A reference to the `caption` def below: 284 + "type": "ref", 285 + "ref": "#caption" 286 + } 287 + } 288 + } 289 + }, 290 + "caption": { 291 + "type": "object", 292 + "properties": {} 293 + } 294 + } 295 + } 296 + ``` 188 297 189 - ### Procedure (XRPC Procedure) 298 + You can also reference `model`s from other namespaces: 190 299 191 300 ```typescript 192 - namespace com.example.createRecord { 193 - /** Create a new record */ 194 - @procedure 195 - op main(input: { 196 - @required text: string; 197 - }): { 198 - @required uri: atUri; 199 - @required cid: cid; 200 - }; 301 + import "@typelex/emitter"; 302 + 303 + namespace app.bsky.actor.profile { 304 + model Main { 305 + // A reference to the `SelfLabel` model from another Lexicon: 306 + labels?: (com.atproto.label.defs.SelfLabels | unknown); 307 + } 308 + } 309 + 310 + namespace com.atproto.label.defs { 311 + model SelfLabels { 312 + // ... 313 + } 201 314 } 202 315 ``` 203 316 204 - **Maps to:** `{"type": "procedure", ...}` with `input` and `output` 317 + This will become a fully qualified reference: 318 + 319 + ```json 320 + // ... 321 + "labels": { 322 + "type": "union", 323 + "refs": ["com.atproto.label.defs#selfLabels"] 324 + } 325 + // ... 326 + ``` 327 + 328 + This will work even if you move the `com.atproto.label.defs` definition to another `.tsp` file, as long as you don't forget to `import` that file. 329 + 330 + As you can see [in the Playground](https://playground.typelex.org/?c=aW1wb3J0ICJAdHlwZWxleC9lbWl0dGVyIjsKCm5hbWVzcGFjZSBhcHAuYnNreS5hY3Rvci5wcm9maWxlIHsKICBAcmVjKCJsaXRlcmFsOnNlbGYiKQogIG1vZGVsIE1haW7FJiAgZGlzcGxheU5hbWU%2FOiBzdHJpbmc7xRpsYWJlbHM%2FOiAoY29tLmF0cHJvdG8uxRYuZGVmcy5TZWxmTMUlIHwgdW5rbm93binEPH0KfewAptY%2F5QCA5gCPy0nEFSAgLy8gLi4uxko%3D&e=%40typelex%2Femitter&options=%7B%7D&vs=%7B%7D), this actually emits *two Lexicon files* since there are two separate namespaces being declared. 331 + 332 + This makes sense if both definitions are yours, but what if you just want to *refer to* someone else's Lexicon? 333 + 334 + ### External Stubs 205 335 206 - ### Subscription (XRPC Subscription) 336 + The easiest way to use someone else's Lexicon is to import its definition, assuming it's also written in TypeSpec. 207 337 208 338 ```typescript 209 - namespace com.example.subscribeRecords { 210 - /** Subscribe to record updates */ 211 - @subscription 212 - op main(cursor?: integer): (Record | Delete); 339 + import "../atproto.tsp" 340 + ``` 341 + 342 + In practice, you probably won't have all the definitions of the Lexicons you depend on written in TypeSpec. However, you can **stub out any definition you depend on**: 343 + 344 + ```typescript 345 + import "@typelex/emitter"; 213 346 214 - model Record { 215 - @required uri: atUri; 216 - @required record: com.example.record.Main; 347 + namespace app.bsky.actor.profile { 348 + model Main { 349 + // A reference to a stub 350 + labels?: (com.atproto.label.defs.SelfLabels | unknown); 217 351 } 352 + } 218 353 219 - model Delete { 220 - @required uri: atUri; 354 + // This is a stub! It's fine for it to be empty. 355 + // Think of it similarly to a .d.ts definition in TypeScript. 356 + namespace com.atproto.label.defs { 357 + model SelfLabels { } 358 + } 359 + ``` 360 + 361 + You could place stubs together and import them from the definitions that need them, and then ignore the (incomplete) Lexicon JSON output for those stubs (since it's going to be empty). 362 + 363 + ```typescript 364 + import "@typelex/emitter"; 365 + import "../atproto-stubs.tsp"; // All your stubs here 366 + 367 + namespace app.bsky.actor.profile { 368 + model Main { 369 + // A reference to a stub 370 + labels?: (com.atproto.label.defs.SelfLabels | unknown); 221 371 } 222 372 } 223 373 ``` 224 374 225 - **Maps to:** `{"type": "subscription", ...}` with `message` containing union 375 + I'm not sure which organizational patterns work best in practice. Try different things and let me know. 376 + 377 + ### Inline Models 226 378 227 - ## Inline vs Definitions 379 + By default, every `model` becomes a top-level entry in Lexicon `defs`. 228 380 229 - **By default, all TypeSpec `model`s become separate Lexicon `defs`.** Use `@inline` to prevent this: 381 + This: 230 382 231 383 ```typescript 232 - // Without @inline - becomes separate def "statusEnum" 233 - union StatusEnum { 234 - "active", 235 - "inactive", 236 - } 384 + import "@typelex/emitter"; 385 + 386 + namespace app.bsky.embed.video { 387 + model Main { 388 + captions?: Caption[]; 389 + } 237 390 238 - // With @inline - inlined where used 239 - @inline 240 - union StatusEnum { 241 - "active", 242 - "inactive", 391 + model Caption { 392 + text?: string 393 + } 243 394 } 244 395 ``` 245 396 246 - Use `@inline` when you want the type directly embedded rather than referenced. 397 + turns into: 247 398 248 - ## Optional vs Required Fields 399 + ```json 400 + { 401 + "lexicon": 1, 402 + "id": "app.bsky.embed.video", 403 + "defs": { 404 + "main": { 405 + // ... 406 + }, 407 + "caption": { 408 + // ... 409 + } 410 + } 411 + } 412 + ``` 249 413 250 - **In lexicons, optional fields are the norm.** Required fields are discouraged and thus need explicit `@required`: 414 + Sometimes, you might want to avoid exposing a model as its own `def`, and you just want it to be expanded inline. Put the `@inline` decorator on the `model` to force it into being inlined at every usage site: 251 415 252 416 ```typescript 253 - model Post { 254 - text?: string; // optional (common) 255 - @required createdAt: datetime; // required (discouraged, needs decorator) 417 + import "@typelex/emitter"; 418 + 419 + namespace app.bsky.embed.video { 420 + model Main { 421 + captions?: Caption[]; 422 + } 423 + 424 + @inline 425 + model Caption { 426 + text?: string 427 + } 256 428 } 257 429 ``` 258 430 259 - **Maps to:** 431 + Then it will be inlined wherever it's being used: 432 + 260 433 ```json 261 434 { 262 - "type": "object", 263 - "required": ["createdAt"], 264 - "properties": { 265 - "text": {"type": "string"}, 266 - "createdAt": {"type": "string", "format": "datetime"} 435 + "lexicon": 1, 436 + "id": "app.bsky.embed.video", 437 + "defs": { 438 + "main": { 439 + "type": "object", 440 + "properties": { 441 + "captions": { 442 + "type": "array", 443 + "items": { 444 + // That's our Caption, inlined. 445 + "type": "object", 446 + "properties": { 447 + "text": { "type": "string" } 448 + } 449 + } 450 + } 451 + } 452 + } 267 453 } 268 454 } 269 455 ``` 270 456 271 - (TypeSpec doesn't require this but I figured we want to make it extra hard to accidentally make a required field.) 457 + Note this means that different usages of `Caption` will have no relation to each other from the Lexicon perspective. The fact that the `Caption` abstraction exists will essentially be erased. 272 458 273 - ## Primitive Types 459 + ## Top-Level Lexicon Types 460 + 461 + In TypeSpec, definitions of things are called [Models](https://typespec.io/docs/language-basics/models/). So you'll see `model Foo { }` used for almost everything, but with different decorators that make its purpose more concrete. 462 + 463 + ### Objects 274 464 275 - | TypeSpec | Lexicon JSON | 276 - |----------|--------------| 277 - | `boolean` | `{"type": "boolean"}` | 278 - | `integer` | `{"type": "integer"}` | 279 - | `string` | `{"type": "string"}` | 280 - | `bytes` | `{"type": "bytes"}` | 281 - | `cidLink` | `{"type": "cid-link"}` | 282 - | `unknown` | `{"type": "unknown"}` | 465 + If you haven't marked your `model` with any decorator, it will be a Lexicon *object*. 283 466 284 - ## Format Types 467 + ```typescript 468 + namespace com.example.post { 469 + model Main { 470 + // ... 471 + } 472 + } 473 + ``` 285 474 286 - Specialized string formats: 475 + becomes 287 476 288 - | TypeSpec | Lexicon Format | 289 - |----------|----------------| 290 - | `atIdentifier` | `at-identifier` - Handle or DID | 291 - | `atUri` | `at-uri` - AT Protocol URI | 292 - | `cid` | `cid` - Content ID | 293 - | `datetime` | `datetime` - ISO 8601 datetime | 294 - | `did` | `did` - DID identifier | 295 - | `handle` | `handle` - Handle identifier | 296 - | `nsid` | `nsid` - Namespaced ID | 297 - | `tid` | `tid` - Timestamp ID | 298 - | `recordKey` | `record-key` - Record key | 299 - | `uri` | `uri` - Generic URI | 300 - | `language` | `language` - Language tag | 477 + ```json 478 + { 479 + "lexicon": 1, 480 + "id": "com.example.post", 481 + "defs": { 482 + "main": { 483 + "type": "object", 484 + "properties": { 485 + // ... 486 + } 487 + } 488 + } 489 + } 490 + ``` 301 491 302 - ## Unions 492 + ### Records 303 493 304 - ### Open Unions (Common Pattern) 494 + Mark a `model` with a `@rec` decorator to make it a Lexicon *record*. 305 495 306 - **Open unions are the default and preferred in lexicons.** Add `unknown` to mark as open: 496 + For example: 307 497 308 498 ```typescript 309 - model Main { 310 - /** Can be any of these types or future additions */ 311 - @required item: TypeA | TypeB | TypeC | unknown; 312 - } 499 + import "@typelex/emitter"; // Don't forget this import! 313 500 314 - model TypeA { 315 - @readOnly @required kind: string = "a"; 316 - @required valueA: string; 501 + namespace com.example.post { 502 + @rec("tid") 503 + model Main { 504 + // ... 505 + } 317 506 } 318 507 ``` 319 508 320 - **Maps to:** 509 + (Note it's `@rec` and not `@record` because unfortunately "record" is reserved in TypeSpec.) 510 + 511 + This becomes: 512 + 321 513 ```json 322 514 { 323 - "properties": { 324 - "item": { 325 - "type": "union", 326 - "refs": ["#typeA", "#typeB", "#typeC"] 515 + "lexicon": 1, 516 + "id": "com.example.post", 517 + "defs": { 518 + "main": { 519 + "type": "record", 520 + "key": "tid", 521 + "record": { 522 + "type": "object", 523 + "properties": { 524 + // ... 525 + } 526 + } 327 527 } 328 528 } 329 529 } 330 530 ``` 331 531 332 - The `unknown` makes it open but doesn't appear in refs. 532 + You can pass any [Record Key Type](https://atproto.com/specs/record-key) to `@rec`, like `@rec("nsid")`, `@rec("literal:self")`, etc. 333 533 334 - ### Known Values (Open String Enum) 534 + ### Queries 535 + 536 + In TypeSpec, functions are defined with [`op`](https://typespec.io/docs/language-basics/operations/) (for "operation"). Since Lexicon distinguishes *queries* and *procedures*, you need to mark an `op` with either `@query` or `@procedure`. 335 537 336 - Suggest values but allow others: 538 + Here is an example query: 337 539 338 540 ```typescript 339 - model Main { 340 - /** Language - suggests common values but allows any */ 341 - lang?: "en" | "es" | "fr" | string; 541 + import "@typelex/emitter"; 542 + 543 + // examples/com/atproto/repo/getRecord.tsp 544 + namespace com.atproto.repo.getRecord { 545 + @query 546 + op main( 547 + @required repo: atIdentifier, 548 + @required collection: nsid, 549 + @required rkey: recordKey, 550 + cid?: cid 551 + ): { 552 + @required uri: atUri; 553 + cid?: cid; 554 + }; 342 555 } 343 556 ``` 344 557 345 - **Maps to:** 558 + The sequential arguments to `main` are rendered as `params` in the generated JSON, while the return type becomes its `output`: 559 + 346 560 ```json 347 561 { 348 - "properties": { 349 - "lang": { 350 - "type": "string", 351 - "knownValues": ["en", "es", "fr"] 562 + "lexicon": 1, 563 + "id": "com.atproto.repo.getRecord", 564 + "defs": { 565 + "main": { 566 + "type": "query", 567 + "parameters": { 568 + "type": "params", 569 + "properties": { 570 + "repo": { /* ... */ }, 571 + "collection": { /* ... */ }, 572 + "rkey": { /* ... */ }, 573 + "cid": { /* ... */ } 574 + }, 575 + "required": ["repo", "collection", "rkey"] 576 + }, 577 + "output": { 578 + "encoding": "application/json", 579 + "schema": { 580 + "type": "object", 581 + "properties": { 582 + "uri": { /* ... */ }, 583 + "cid": { /* ... */ } 584 + }, 585 + "required": ["uri"] 586 + } 587 + } 352 588 } 353 589 } 354 590 } 355 591 ``` 356 592 357 - ### Closed Unions (Discouraged) 593 + Note that `encoding` is assumed to be `"application/json"`. You can override it with the `@encoding` decorator: 358 594 359 - **⚠️ Closed unions are discouraged in lexicons** as they prevent future additions. Use only when absolutely necessary: 595 + ```typescript 596 + @query 597 + @encoding("foo/bar") 598 + op main( 599 + // ... 600 + ``` 601 + 602 + You may also declare `errors` with the `@errors` decorator like so: 360 603 361 604 ```typescript 362 - @closed 363 - @inline 364 - union Action { 365 - Create, 366 - Update, 367 - Delete, 605 + import "@typelex/emitter"; 606 + 607 + namespace com.atproto.repo.getRecord { 608 + @query 609 + @errors(FooError, BarError) 610 + op main( 611 + // ... 612 + ): { 613 + // ... 614 + }; 615 + 616 + model FooError {} 617 + model BarError {} 368 618 } 619 + ``` 620 + 621 + If you don't like writing the output definition inline as the `main` return type, you can extract it to a separate `model`. You'll probably want to mark that model as `@inline` so it doesn't clutter the `defs`. 622 + 623 + ### Procedures 369 624 370 - model Main { 371 - @required action: Action; 625 + Procedures are declared with `@procedure op`: 626 + 627 + ```typescript 628 + import "@typelex/emitter"; 629 + 630 + namespace com.example.createRecord { 631 + /** Create a new record */ 632 + @procedure 633 + op main(input: { 634 + @required text: string; 635 + }): { 636 + @required uri: atUri; 637 + @required cid: cid; 638 + }; 372 639 } 373 640 ``` 374 641 375 - **Maps to:** 642 + Note that, unlike with queries, you can't change the argument names. The first argument *must* be called `input`. It will correspond to the `input` section inside the procedure's JSON: 643 + 376 644 ```json 377 645 { 378 - "properties": { 379 - "action": { 380 - "type": "union", 381 - "refs": ["#create", "#update", "#delete"], 382 - "closed": true 646 + "lexicon": 1, 647 + "id": "com.example.createRecord", 648 + "defs": { 649 + "main": { 650 + "type": "procedure", 651 + "description": "Create a new record", 652 + "input": { 653 + "encoding": "application/json", 654 + "schema": { 655 + "type": "object", 656 + "properties": { 657 + "text": { "type": "string" } 658 + }, 659 + "required": ["text"] 660 + } 661 + }, 662 + "output": { 663 + "encoding": "application/json", 664 + "schema": { 665 + "type": "object", 666 + "properties": { 667 + "uri": { /* ... */ }, 668 + "cid": { /* ... */ } 669 + }, 670 + "required": ["uri", "cid"] 671 + } 672 + } 383 673 } 384 674 } 385 675 } 386 676 ``` 387 677 388 - ### Closed Enums (Discouraged) 678 + Although this is very rarely done, procedures can also receive parameters (like queries). However, they are declared inside an optional second object argument like `@procedure op(input: {}, parameters: {})`. 679 + 680 + Use `: void` for procedures with no output: 681 + 682 + ```typescript 683 + @procedure 684 + op main(input: { 685 + @required uri: atUri; 686 + }): void; 687 + ``` 389 688 390 - **⚠️ Closed enums are also discouraged.** Use `@closed @inline union` for fixed value sets: 689 + Use `: never` with `@encoding()` for output with encoding but no schema: 391 690 392 691 ```typescript 393 - @closed 394 - @inline 395 - union Status { 396 - "active", 397 - "inactive", 398 - "pending", 692 + @query 693 + @encoding("application/json") 694 + op main(id?: string): never; 695 + ``` 696 + 697 + ### Subscriptions 698 + 699 + Defining an `op` with `@subscription` gives you a subscription: 700 + 701 + ```typescript 702 + import "@typelex/emitter"; 703 + 704 + namespace com.atproto.sync.subscribeRepos { 705 + @subscription 706 + @errors(FutureCursor, ConsumerTooSlow) 707 + op main( 708 + cursor?: integer 709 + ): Commit | Sync | unknown; 710 + 711 + model Commit { 712 + // ... 713 + } 714 + 715 + model Sync { 716 + // ... 717 + } 718 + 719 + model FutureCursor {} 720 + model ConsumerTooSlow {} 399 721 } 400 722 ``` 401 723 402 - **Maps to:** 724 + This becomes: 725 + 403 726 ```json 404 727 { 405 - "type": "string", 406 - "enum": ["active", "inactive", "pending"] 728 + "lexicon": 1, 729 + "id": "com.atproto.sync.subscribeRepos", 730 + "defs": { 731 + "main": { 732 + "type": "subscription", 733 + "parameters": { 734 + "type": "params", 735 + "properties": { 736 + "cursor": { /* ... */ } 737 + } 738 + }, 739 + "message": { 740 + "schema": { 741 + "type": "union", 742 + "refs": ["#commit", "#sync"] 743 + } 744 + }, 745 + "errors": [{ "name": "FutureCursor" }, { "name": "ConsumerTooSlow" }] 746 + }, 747 + "commit": { /* ... */ }, 748 + "sync": { /* ... */ } 749 + } 407 750 } 408 751 ``` 409 752 410 - Integer enums work the same way: 753 + ### Tokens 754 + 755 + Empty models marked with `@token` become token definitions: 411 756 412 757 ```typescript 413 - @closed 414 - @inline 415 - union Fibonacci { 416 - 1, 2, 3, 5, 8, 758 + /** Indicates spam content */ 759 + @token 760 + model ReasonSpam {} 761 + 762 + /** Indicates policy violation */ 763 + @token 764 + model ReasonViolation {} 765 + 766 + model Report { 767 + @required reason: (ReasonSpam | ReasonViolation | unknown); 768 + } 769 + ``` 770 + 771 + **Maps to:** 772 + ```json 773 + { 774 + "report": { 775 + "properties": { 776 + "reason": { 777 + "type": "union", 778 + "refs": ["#reasonSpam", "#reasonViolation"] 779 + } 780 + } 781 + }, 782 + "reasonSpam": { 783 + "type": "token", 784 + "description": "Indicates spam content" 785 + } 417 786 } 418 787 ``` 419 788 420 - ## Arrays 789 + ## Data Types 790 + 791 + All [Lexicon data types](https://atproto.com/specs/lexicon#overview-of-types) are supported. 792 + 793 + ### Primitive Types 794 + 795 + | TypeSpec | Lexicon JSON | 796 + |----------|--------------| 797 + | `boolean` | `{"type": "boolean"}` | 798 + | `integer` | `{"type": "integer"}` | 799 + | `string` | `{"type": "string"}` | 800 + | `bytes` | `{"type": "bytes"}` | 801 + | `cidLink` | `{"type": "cid-link"}` | 802 + | `unknown` | `{"type": "unknown"}` | 803 + 804 + ### Format Types 805 + 806 + Specialized string formats: 807 + 808 + | TypeSpec | Lexicon Format | 809 + |----------|----------------| 810 + | `atIdentifier` | `at-identifier` - Handle or DID | 811 + | `atUri` | `at-uri` - AT Protocol URI | 812 + | `cid` | `cid` - Content ID | 813 + | `datetime` | `datetime` - ISO 8601 datetime | 814 + | `did` | `did` - DID identifier | 815 + | `handle` | `handle` - Handle identifier | 816 + | `nsid` | `nsid` - Namespaced ID | 817 + | `tid` | `tid` - Timestamp ID | 818 + | `recordKey` | `record-key` - Record key | 819 + | `uri` | `uri` - Generic URI | 820 + | `language` | `language` - Language tag | 821 + 822 + ### Arrays 421 823 422 824 Use `[]` suffix: 423 825 ··· 443 845 444 846 **Note:** `@minItems`/`@maxItems` in TypeSpec map to `minLength`/`maxLength` in JSON. 445 847 446 - ## Blobs 848 + ### Blobs 447 849 448 850 ```typescript 449 851 model Main { ··· 470 872 } 471 873 ``` 472 874 473 - ## References 875 + ## Required and Optional Fields 474 876 475 - ### Local References 476 - 477 - Same namespace, uses `#`: 877 + In Lexicon, the default is to make fields optional. Use `?:` for that: 478 878 479 879 ```typescript 480 - model Main { 481 - metadata?: Metadata; 482 - } 880 + import "@typelex/emitter"; 483 881 484 - model Metadata { 485 - @required key: string; 882 + namespace tools.ozone.moderation.defs { 883 + model SubjectStatusView { 884 + subjectBlobCids?: cid[]; 885 + subjectRepoHandle?: string; 886 + } 486 887 } 487 888 ``` 488 889 489 - **Maps to:** `{"type": "ref", "ref": "#metadata"}` 890 + This becomes: 490 891 491 - ### External References 892 + ```json 893 + { 894 + "lexicon": 1, 895 + "id": "tools.ozone.moderation.defs", 896 + "defs": { 897 + "subjectStatusView": { 898 + "type": "object", 899 + "properties": { 900 + "subjectBlobCids": { 901 + "type": "array", 902 + "items": { "type": "string", "format": "cid" } 903 + }, 904 + "subjectRepoHandle": { "type": "string" } 905 + } 906 + } 907 + } 908 + } 909 + ``` 492 910 493 - Different namespace to specific def: 911 + Think twice before adding a required field because you won't be able to make it optional later without violating the schema contract (and making all existing records invalid). In practice, this often means you'd have to deprecate the field and create another field, which is messy. 912 + 913 + This is why required fields need a special `@required` decorator forcing you to think twice. 494 914 495 915 ```typescript 496 - model Main { 497 - externalRef?: com.example.defs.Metadata; 916 + import "@typelex/emitter"; 917 + 918 + namespace tools.ozone.moderation.defs { 919 + model SubjectStatusView { 920 + subjectBlobCids?: cid[]; 921 + subjectRepoHandle?: string; 922 + 923 + @required createdAt: datetime; 924 + } 498 925 } 499 926 ``` 500 927 501 - **Maps to:** `{"type": "ref", "ref": "com.example.defs#metadata"}` 502 - 503 - Different namespace to main def (no fragment): 928 + This becomes: 504 929 505 - ```typescript 506 - model Main { 507 - mainRef?: com.example.post.Main; 508 - } 930 + ```json 931 + // ... 932 + "required": ["createdAt"] 509 933 ``` 510 934 511 - **Maps to:** `{"type": "ref", "ref": "com.example.post"}` 935 + ## Unions 936 + 937 + ### Open Unions (Recommended) 512 938 513 - ## Tokens 939 + In Lexicon, unions like "A or B" default to being *open*, i.e. allowing you to add C in the future. 514 940 515 - Empty models marked with `@token`: 941 + To declare an open union, write `| unknown` at the end, like `Images | Video | unknown`: 516 942 517 943 ```typescript 518 - /** Indicates spam content */ 519 - @token 520 - model ReasonSpam {} 944 + import "@typelex/emitter"; 945 + 946 + namespace app.bsky.feed.post { 947 + model Main { 948 + embed?: Images | Video | unknown; 949 + } 521 950 522 - /** Indicates policy violation */ 523 - @token 524 - model ReasonViolation {} 951 + model Images { 952 + // ... 953 + } 525 954 526 - model Report { 527 - @required reason: (ReasonSpam | ReasonViolation | unknown); 955 + model Video { 956 + // ... 957 + } 528 958 } 529 959 ``` 530 960 531 - **Maps to:** 961 + This produces a `union`: 962 + 532 963 ```json 533 964 { 534 - "report": { 535 - "properties": { 536 - "reason": { 537 - "type": "union", 538 - "refs": ["#reasonSpam", "#reasonViolation"] 965 + "lexicon": 1, 966 + "id": "app.bsky.feed.post", 967 + "defs": { 968 + "main": { 969 + "type": "object", 970 + "properties": { 971 + "embed": { 972 + "type": "union", 973 + "refs": ["#images", "#video"] 974 + } 539 975 } 540 - } 541 - }, 542 - "reasonSpam": { 543 - "type": "token", 544 - "description": "Indicates spam content" 976 + }, 977 + "images": { /* ... */ }, 978 + "video": { /* ... */ } 545 979 } 546 980 } 547 981 ``` 548 982 549 - ## Operation Details 983 + Then later you can add more types to the union. 550 984 551 - ### Query Parameters 985 + You may also write the same thing with the `union` syntax instead of writing `A | B | C | unknown` directly: 552 986 553 987 ```typescript 554 - @query 555 - op main( 556 - @required search: string, 557 - limit?: integer = 50, 558 - tags?: string[] 559 - ): { ... }; 988 + namespace app.bsky.feed.post { 989 + model Main { 990 + embed?: EmbedType; 991 + } 992 + 993 + @inline union EmbedType { Images, Video, unknown } 994 + 995 + model Images { 996 + // ... 997 + } 998 + 999 + model Video { 1000 + // ... 1001 + } 1002 + } 560 1003 ``` 561 1004 562 - Parameters can be inline with decorators before each. 1005 + This is completely equivalent. The `@inline` decorator states that it doesn't become a part of your defs. 1006 + 1007 + ### Known Values (Open Enums) 563 1008 564 - ### Procedure with Input and Parameters 1009 + You can suggest common values but allow others: 565 1010 566 1011 ```typescript 567 - @procedure 568 - op main( 569 - input: { 570 - @required data: string; 571 - }, 572 - parameters: { 573 - @required repo: atIdentifier; 574 - validate?: boolean = true; 1012 + import "@typelex/emitter"; 1013 + 1014 + namespace com.example { 1015 + model Main { 1016 + lang?: "en" | "es" | "fr" | string; 575 1017 } 576 - ): { ... }; 1018 + } 577 1019 ``` 578 1020 579 - Use `input:` for body, `parameters:` for query params. 1021 + Note the `| string` that says you may add more "known" values later. 580 1022 581 - ### No Output 1023 + The `union` syntax also works for this: 582 1024 583 1025 ```typescript 584 - @procedure 585 - op main(input: { 586 - @required uri: atUri; 587 - }): void; 1026 + import "@typelex/emitter"; 1027 + 1028 + namespace com.example { 1029 + model Main { 1030 + lang?: Languages; 1031 + } 1032 + 1033 + @inline union Languages { "en", "es", "fr", string } 1034 + } 588 1035 ``` 589 1036 590 - Use `: void` for procedures with no output. 1037 + Here, `@inline` prevents creation of a `languages` top-level def. If you *do* want to make it reusable from other Lexicons, remove the `@inline` annotation and refer to `com.example.Languages` from somewhere else. 1038 + 1039 + ### Closed Unions and Enums (Discouraged) 1040 + 1041 + If you want to create a *closed* union (which are **heavily discouraged** in Lexicon), you can mark your union with a `@closed` decorator. This will let you remove the `unknown` case from it. 591 1042 592 - ### Output Without Schema 1043 + There is no shorthand notation for closed unions, so you'll have to write `@closed @inline union { A, B }`. 593 1044 594 1045 ```typescript 595 - @query 596 - @encoding("application/json") 597 - op main(id?: string): never; 1046 + import "@typelex/emitter"; 1047 + 1048 + namespace com.atproto.repo.applyWrites { 1049 + @procedure 1050 + op main(input: { 1051 + @required 1052 + writes: WriteAction[]; 1053 + }): { 1054 + // ... 1055 + }; 1056 + 1057 + @closed // Discouraged! 1058 + @inline 1059 + union WriteAction { Create, Update, Delete, } 1060 + 1061 + model Create { 1062 + // ... 1063 + } 1064 + 1065 + model Update { 1066 + // ... 1067 + } 1068 + 1069 + model Delete { 1070 + // ... 1071 + } 1072 + } 598 1073 ``` 599 1074 600 - Use `: never` with `@encoding()` for output with encoding but no schema. 1075 + This gives you: 1076 + 1077 + ```json 1078 + "writes": { 1079 + "type": "array", 1080 + "items": { 1081 + "type": "union", 1082 + "refs": ["#create", "#update", "#delete"], 1083 + "closed": true 1084 + } 1085 + } 1086 + ``` 601 1087 602 - ### Errors 1088 + You can also declare this with strings or numbers: 603 1089 604 1090 ```typescript 605 - /** The provided text is invalid */ 606 - model InvalidText {} 1091 + @closed // Discouraged! 1092 + @inline 1093 + union WriteAction { "create", "update", "delete" } 1094 + ``` 607 1095 608 - /** User not found */ 609 - model NotFound {} 1096 + These would become a Lexicon closed `enum`: 610 1097 611 - @procedure 612 - @errors(InvalidText, NotFound) 613 - op main(...): ...; 1098 + ```json 1099 + "items": { 1100 + "type": "string", 1101 + "enum": ["create", "update", "delete"] 1102 + } 614 1103 ``` 615 1104 616 - Empty models with descriptions become error definitions. 1105 + Avoid closed unions and closed enums if you can. 617 1106 618 1107 ## Constraints 619 1108 ··· 708 1197 model Main { 709 1198 @required createdAt: datetime; 710 1199 updatedAt?: datetime | null; // can be omitted or null 711 - deletedAt?: datetime; // can only be omitted 1200 + deletedAt?: datetime; // can only be omitted 712 1201 } 713 1202 ``` 714 1203 ··· 721 1210 } 722 1211 ``` 723 1212 724 - ## Common Patterns 725 - 726 - ### Discriminated Unions 727 - 728 - Use `@readOnly` with const for discriminator: 729 - 730 - ```typescript 731 - model Create { 732 - @readOnly @required type: string = "create"; 733 - @required data: string; 734 - } 735 - 736 - model Update { 737 - @readOnly @required type: string = "update"; 738 - @required id: string; 739 - } 740 - ``` 741 - 742 - ### Nested Unions 743 - 744 - ```typescript 745 - model Container { 746 - @required id: string; 747 - @required payload: (PayloadA | PayloadB | unknown); 748 - } 749 - ``` 750 - 751 - Unions can be nested in objects and arrays. 752 - 753 1213 ## Naming Conventions 754 1214 755 1215 Model names convert from PascalCase to camelCase in defs: ··· 759 1219 model UserMetadata { ... } // becomes "userMetadata" 760 1220 model Main { ... } // becomes "main" 761 1221 ``` 762 - 763 - ## Decorator Style 764 - 765 - - Single `@required` goes on same line: `@required text: string` 766 - - Multiple decorators go on separate lines with blank line after: 767 - ```typescript 768 - @minLength(1) 769 - @maxLength(100) 770 - text?: string; 771 - ```