···457458## Strings with Known Values
459460-### Inline (Property Level)
461462<table>
463<tr><th>TypeSpec</th><th>Lexicon JSON</th></tr>
···507508**Atproto idiom:** Always include `| string` for extensibility. This allows new values without breaking old clients.
509000000000000000000000000000000000000000000000510### Named (Def Level)
511512<table>
···689690```typespec
691namespace com.atproto.repo.applyWrites {
0000000000000000692 @procedure
693 op main(input: {
694 @required repo: atIdentifier;
695696 // Closed union - write operations are fixed
697 @required
698- writes: Closed<Create | Update | Delete>[];
699 }): {
700- results?: Closed<CreateResult | UpdateResult | DeleteResult>[];
701 };
702703 model Create {
···812- Internal server operations (like applyWrites)
813- Batch operations with fixed types
814- NOT for user-facing content or records
00815816### Empty Union
817···1609| `{ "type": "ref", "ref": "ns.id#foo" }` | `ns.id.Foo` | Cross namespace def |
1610| `{ "type": "ref", "ref": "ns.id" }` | `ns.id.Main` | Cross namespace main |
1611| `{ "type": "union", "refs": [...] }` | `(A \| B \| unknown)` | **Default - always use** |
1612-| `{ "type": "union", "refs": [...], "closed": true }` | `Closed<A \| B>` | **Rare - internal ops only** |
1613| `{ "type": "union", "refs": [] }` | `(never \| unknown)` | Empty union |
1614| `{ "type": "string", "knownValues": [...] }` | `"a" \| "b" \| string` | **Always include string** |
01615| `{ "type": "token" }` | `@token model M {}` | String constant |
1616| `{ "type": "record", "key": "tid" }` | `@record("tid") model Main` | |
1617| `{ "type": "query" }` | `@query op main(...)` | Usually with limit/cursor |
···457458## Strings with Known Values
459460+### Open Enums (Inline, Property Level)
461462<table>
463<tr><th>TypeSpec</th><th>Lexicon JSON</th></tr>
···507508**Atproto idiom:** Always include `| string` for extensibility. This allows new values without breaking old clients.
509510+### Closed Enums (Inline, Rare)
511+512+For **truly fixed** string sets that will never change:
513+514+<table>
515+<tr><th>TypeSpec</th><th>Lexicon JSON</th></tr>
516+<tr><td>
517+518+```typespec
519+model Config {
520+ @closed
521+ @inline
522+ union Status {
523+ "draft",
524+ "published",
525+ "archived",
526+ }
527+528+ @doc("Current status - fixed set")
529+ @required
530+ status: Status;
531+}
532+```
533+534+</td><td>
535+536+```json
537+{
538+ "type": "object",
539+ "required": ["status"],
540+ "properties": {
541+ "status": {
542+ "type": "string",
543+ "enum": ["draft", "published", "archived"],
544+ "description": "Current status - fixed set"
545+ }
546+ }
547+}
548+```
549+550+</td></tr>
551+</table>
552+553+**Note:** Closed enums use `enum` (not `knownValues`). Only use for values that will **never** expand.
554+555### Named (Def Level)
556557<table>
···734735```typespec
736namespace com.atproto.repo.applyWrites {
737+ @closed
738+ @inline
739+ union WriteAction {
740+ Create,
741+ Update,
742+ Delete,
743+ }
744+745+ @closed
746+ @inline
747+ union WriteResult {
748+ CreateResult,
749+ UpdateResult,
750+ DeleteResult,
751+ }
752+753 @procedure
754 op main(input: {
755 @required repo: atIdentifier;
756757 // Closed union - write operations are fixed
758 @required
759+ writes: WriteAction[];
760 }): {
761+ results?: WriteResult[];
762 };
763764 model Create {
···873- Internal server operations (like applyWrites)
874- Batch operations with fixed types
875- NOT for user-facing content or records
876+877+**Note:** Use `@closed @inline` together to create a closed union that stays inline instead of becoming a separate def.
878879### Empty Union
880···1672| `{ "type": "ref", "ref": "ns.id#foo" }` | `ns.id.Foo` | Cross namespace def |
1673| `{ "type": "ref", "ref": "ns.id" }` | `ns.id.Main` | Cross namespace main |
1674| `{ "type": "union", "refs": [...] }` | `(A \| B \| unknown)` | **Default - always use** |
1675+| `{ "type": "union", "refs": [...], "closed": true }` | `@closed @inline union { A, B }` | **Rare - internal ops only** |
1676| `{ "type": "union", "refs": [] }` | `(never \| unknown)` | Empty union |
1677| `{ "type": "string", "knownValues": [...] }` | `"a" \| "b" \| string` | **Always include string** |
1678+| `{ "type": "string", "enum": [...] }` | `@closed @inline union { "a", "b" }` | **Rare - truly fixed strings** |
1679| `{ "type": "token" }` | `@token model M {}` | String constant |
1680| `{ "type": "record", "key": "tid" }` | `@record("tid") model Main` | |
1681| `{ "type": "query" }` | `@query op main(...)` | Usually with limit/cursor |
-14
packages/emitter/lib/main.tsp
···34 _blob: never;
35}
3637-/**
38- * Creates a closed union from an inline union expression.
39- *
40- * This is an explicit opt-in for closed unions when written inline.
41- * For named unions, use the @closed decorator instead.
42- *
43- * @template T A union type without `unknown` variant
44- *
45- * @example Closed inline union
46- * ```typespec
47- * embed?: Closed<Images | External | Record>;
48- * ```
49- */
50-model Closed<T> {}
5152/**
53 * AT Protocol format scalars.
···34 _blob: never;
35}
36000000000000003738/**
39 * AT Protocol format scalars.
+27-33
packages/emitter/src/emitter.ts
···326 const name = (union as any).name;
327 if (!name) return;
328000329 const unionDef: any = this.typeToLexiconDefinition(union, undefined, true);
330 if (!unionDef) return;
331332 if (
333 unionDef.type === "union" ||
334- (unionDef.type === "string" && unionDef.knownValues)
335 ) {
336 const defName = name.charAt(0).toLowerCase() + name.slice(1);
337 const description = getDoc(this.program, union);
···356 );
357 }
358359- private isClosedUnionTemplate(model: Model): boolean {
360- return !!(
361- model.name === "Closed" ||
362- (model.node && (model.node as any).symbol?.name === "Closed") ||
363- (isTemplateInstance(model) &&
364- model.node &&
365- (model.node as any).symbol?.name === "Closed")
366- );
367- }
368369 private createBlobDef(model: Model): LexiconBlob {
370 const blobDef: LexiconBlob = { type: "blob" };
···407 // Parse union variants
408 const variants = this.parseUnionVariants(unionType);
409410- // Case 1: String enum with known values (string literals + string type)
411- if (variants.isStringEnum) {
000000412 return this.createStringEnumDef(unionType, variants.stringLiterals, prop);
413 }
414···428 return null;
429 }
430431- // Case 4: Invalid string literal union (has literals but no string type)
432 if (variants.stringLiterals.length > 0 && !variants.hasStringType) {
433 throw new Error(
434- 'String literal unions must include "| string" to allow unknown values',
0435 );
436 }
437···487 stringLiterals: string[],
488 prop?: ModelProperty,
489 ): LexiconDefinition {
490- const primitive: any = { type: "string", knownValues: stringLiterals };
00000491492 // Apply constraints
493 const maxLength = getMaxLength(this.program, unionType);
···885 return this.addDescription(this.createBlobDef(model), propDesc);
886 }
887888- // 2. Check for Closed<Union> template
889- if (this.isClosedUnionTemplate(model)) {
890- const unionArg = model.templateMapper?.args?.[0];
891- if (unionArg && isType(unionArg) && unionArg.kind === "Union") {
892- const unionDef = this.typeToLexiconDefinition(unionArg, prop);
893- if (unionDef && unionDef.type === "union") {
894- (unionDef as LexiconUnion).closed = true;
895- return unionDef;
896- }
897- }
898- throw new Error(
899- "Closed<> template argument must be a union of model references",
900- );
901- }
902-903- // 3. Check for model reference (named models from other namespaces)
904 const modelRef = this.getModelReference(model);
905 if (modelRef) {
906 return this.addDescription({ type: "ref", ref: modelRef }, propDesc);
907 }
908909- // 4. Check for array type
910 if (isArrayModelType(this.program, model)) {
911 const arrayDef = this.modelToLexiconArray(model, prop);
912 if (!arrayDef) {
···917 return this.addDescription(arrayDef, propDesc);
918 }
919920- // 5. Inline object
921 return this.addDescription(this.modelToLexiconObject(model), propDesc);
922 }
923···1124 const unionName = (union as any).name;
1125 const namespace = (union as any).namespace;
1126 if (!unionName || !namespace || namespace.name === "TypeSpec") return null;
00011271128 const namespaceName = getNamespaceFullName(namespace);
1129 if (!namespaceName) return null;
···326 const name = (union as any).name;
327 if (!name) return;
328329+ // Skip @inline unions - they should be inlined, not defined separately
330+ if (isInline(this.program, union)) return;
331+332 const unionDef: any = this.typeToLexiconDefinition(union, undefined, true);
333 if (!unionDef) return;
334335 if (
336 unionDef.type === "union" ||
337+ (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum))
338 ) {
339 const defName = name.charAt(0).toLowerCase() + name.slice(1);
340 const description = getDoc(this.program, union);
···359 );
360 }
361000000000362363 private createBlobDef(model: Model): LexiconBlob {
364 const blobDef: LexiconBlob = { type: "blob" };
···401 // Parse union variants
402 const variants = this.parseUnionVariants(unionType);
403404+ // Case 1: String enum (string literals with or without string type)
405+ // isStringEnum: has literals + string type + no refs
406+ // Closed enum: has literals + no string type + no refs + @closed
407+ if (variants.isStringEnum ||
408+ (variants.stringLiterals.length > 0 &&
409+ !variants.hasStringType &&
410+ variants.unionRefs.length === 0 &&
411+ isClosed(this.program, unionType))) {
412 return this.createStringEnumDef(unionType, variants.stringLiterals, prop);
413 }
414···428 return null;
429 }
430431+ // Case 4: Invalid string literal union (has literals but no string type and not @closed)
432 if (variants.stringLiterals.length > 0 && !variants.hasStringType) {
433 throw new Error(
434+ 'String literal unions must include "| string" to allow unknown values. ' +
435+ 'Use @closed decorator if this is intentionally a fixed enum.',
436 );
437 }
438···488 stringLiterals: string[],
489 prop?: ModelProperty,
490 ): LexiconDefinition {
491+ // Use "enum" for @closed unions, "knownValues" for open unions
492+ const isClosedUnion = isClosed(this.program, unionType);
493+ const primitive: any = {
494+ type: "string",
495+ [isClosedUnion ? "enum" : "knownValues"]: stringLiterals,
496+ };
497498 // Apply constraints
499 const maxLength = getMaxLength(this.program, unionType);
···891 return this.addDescription(this.createBlobDef(model), propDesc);
892 }
893894+ // 2. Check for model reference (named models from other namespaces)
000000000000000895 const modelRef = this.getModelReference(model);
896 if (modelRef) {
897 return this.addDescription({ type: "ref", ref: modelRef }, propDesc);
898 }
899900+ // 3. Check for array type
901 if (isArrayModelType(this.program, model)) {
902 const arrayDef = this.modelToLexiconArray(model, prop);
903 if (!arrayDef) {
···908 return this.addDescription(arrayDef, propDesc);
909 }
910911+ // 4. Inline object
912 return this.addDescription(this.modelToLexiconObject(model), propDesc);
913 }
914···1115 const unionName = (union as any).name;
1116 const namespace = (union as any).namespace;
1117 if (!unionName || !namespace || namespace.name === "TypeSpec") return null;
1118+1119+ // If union is marked as @inline, don't create a reference - inline it instead
1120+ if (isInline(this.program, union)) return null;
11211122 const namespaceName = getNamespaceFullName(namespace);
1123 if (!namespaceName) return null;
···4 @doc("Indicates that the 'swapCommit' parameter did not match current commit.")
5 model InvalidSwap {}
600000000000000007 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.")
8 @procedure
9 @errors(InvalidSwap)
···16 validate?: boolean;
1718 @required
19- writes: Closed<Create | Update | Delete>[];
2021 @doc("If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.")
22 swapCommit?: cid;
23 }): {
24 commit?: com.atproto.repo.defs.CommitMeta;
25- results?: Closed<CreateResult | UpdateResult | DeleteResult>[];
26 };
2728 @doc("Operation which creates a new record.")
···4 @doc("Indicates that the 'swapCommit' parameter did not match current commit.")
5 model InvalidSwap {}
67+ @closed
8+ @inline
9+ union WriteAction {
10+ Create,
11+ Update,
12+ Delete,
13+ }
14+15+ @closed
16+ @inline
17+ union WriteResult {
18+ CreateResult,
19+ UpdateResult,
20+ DeleteResult,
21+ }
22+23 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.")
24 @procedure
25 @errors(InvalidSwap)
···32 validate?: boolean;
3334 @required
35+ writes: WriteAction[];
3637 @doc("If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.")
38 swapCommit?: cid;
39 }): {
40 commit?: com.atproto.repo.defs.CommitMeta;
41+ results?: WriteResult[];
42 };
4344 @doc("Operation which creates a new record.")