···457457458458## Strings with Known Values
459459460460-### Inline (Property Level)
460460+### Open Enums (Inline, Property Level)
461461462462<table>
463463<tr><th>TypeSpec</th><th>Lexicon JSON</th></tr>
···507507508508**Atproto idiom:** Always include `| string` for extensibility. This allows new values without breaking old clients.
509509510510+### Closed Enums (Inline, Rare)
511511+512512+For **truly fixed** string sets that will never change:
513513+514514+<table>
515515+<tr><th>TypeSpec</th><th>Lexicon JSON</th></tr>
516516+<tr><td>
517517+518518+```typespec
519519+model Config {
520520+ @closed
521521+ @inline
522522+ union Status {
523523+ "draft",
524524+ "published",
525525+ "archived",
526526+ }
527527+528528+ @doc("Current status - fixed set")
529529+ @required
530530+ status: Status;
531531+}
532532+```
533533+534534+</td><td>
535535+536536+```json
537537+{
538538+ "type": "object",
539539+ "required": ["status"],
540540+ "properties": {
541541+ "status": {
542542+ "type": "string",
543543+ "enum": ["draft", "published", "archived"],
544544+ "description": "Current status - fixed set"
545545+ }
546546+ }
547547+}
548548+```
549549+550550+</td></tr>
551551+</table>
552552+553553+**Note:** Closed enums use `enum` (not `knownValues`). Only use for values that will **never** expand.
554554+510555### Named (Def Level)
511556512557<table>
···689734690735```typespec
691736namespace com.atproto.repo.applyWrites {
737737+ @closed
738738+ @inline
739739+ union WriteAction {
740740+ Create,
741741+ Update,
742742+ Delete,
743743+ }
744744+745745+ @closed
746746+ @inline
747747+ union WriteResult {
748748+ CreateResult,
749749+ UpdateResult,
750750+ DeleteResult,
751751+ }
752752+692753 @procedure
693754 op main(input: {
694755 @required repo: atIdentifier;
695756696757 // Closed union - write operations are fixed
697758 @required
698698- writes: Closed<Create | Update | Delete>[];
759759+ writes: WriteAction[];
699760 }): {
700700- results?: Closed<CreateResult | UpdateResult | DeleteResult>[];
761761+ results?: WriteResult[];
701762 };
702763703764 model Create {
···812873- Internal server operations (like applyWrites)
813874- Batch operations with fixed types
814875- NOT for user-facing content or records
876876+877877+**Note:** Use `@closed @inline` together to create a closed union that stays inline instead of becoming a separate def.
815878816879### Empty Union
817880···16091672| `{ "type": "ref", "ref": "ns.id#foo" }` | `ns.id.Foo` | Cross namespace def |
16101673| `{ "type": "ref", "ref": "ns.id" }` | `ns.id.Main` | Cross namespace main |
16111674| `{ "type": "union", "refs": [...] }` | `(A \| B \| unknown)` | **Default - always use** |
16121612-| `{ "type": "union", "refs": [...], "closed": true }` | `Closed<A \| B>` | **Rare - internal ops only** |
16751675+| `{ "type": "union", "refs": [...], "closed": true }` | `@closed @inline union { A, B }` | **Rare - internal ops only** |
16131676| `{ "type": "union", "refs": [] }` | `(never \| unknown)` | Empty union |
16141677| `{ "type": "string", "knownValues": [...] }` | `"a" \| "b" \| string` | **Always include string** |
16781678+| `{ "type": "string", "enum": [...] }` | `@closed @inline union { "a", "b" }` | **Rare - truly fixed strings** |
16151679| `{ "type": "token" }` | `@token model M {}` | String constant |
16161680| `{ "type": "record", "key": "tid" }` | `@record("tid") model Main` | |
16171681| `{ "type": "query" }` | `@query op main(...)` | Usually with limit/cursor |
-14
packages/emitter/lib/main.tsp
···3434 _blob: never;
3535}
36363737-/**
3838- * Creates a closed union from an inline union expression.
3939- *
4040- * This is an explicit opt-in for closed unions when written inline.
4141- * For named unions, use the @closed decorator instead.
4242- *
4343- * @template T A union type without `unknown` variant
4444- *
4545- * @example Closed inline union
4646- * ```typespec
4747- * embed?: Closed<Images | External | Record>;
4848- * ```
4949- */
5050-model Closed<T> {}
51375238/**
5339 * AT Protocol format scalars.
+27-33
packages/emitter/src/emitter.ts
···326326 const name = (union as any).name;
327327 if (!name) return;
328328329329+ // Skip @inline unions - they should be inlined, not defined separately
330330+ if (isInline(this.program, union)) return;
331331+329332 const unionDef: any = this.typeToLexiconDefinition(union, undefined, true);
330333 if (!unionDef) return;
331334332335 if (
333336 unionDef.type === "union" ||
334334- (unionDef.type === "string" && unionDef.knownValues)
337337+ (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum))
335338 ) {
336339 const defName = name.charAt(0).toLowerCase() + name.slice(1);
337340 const description = getDoc(this.program, union);
···356359 );
357360 }
358361359359- private isClosedUnionTemplate(model: Model): boolean {
360360- return !!(
361361- model.name === "Closed" ||
362362- (model.node && (model.node as any).symbol?.name === "Closed") ||
363363- (isTemplateInstance(model) &&
364364- model.node &&
365365- (model.node as any).symbol?.name === "Closed")
366366- );
367367- }
368362369363 private createBlobDef(model: Model): LexiconBlob {
370364 const blobDef: LexiconBlob = { type: "blob" };
···407401 // Parse union variants
408402 const variants = this.parseUnionVariants(unionType);
409403410410- // Case 1: String enum with known values (string literals + string type)
411411- if (variants.isStringEnum) {
404404+ // Case 1: String enum (string literals with or without string type)
405405+ // isStringEnum: has literals + string type + no refs
406406+ // Closed enum: has literals + no string type + no refs + @closed
407407+ if (variants.isStringEnum ||
408408+ (variants.stringLiterals.length > 0 &&
409409+ !variants.hasStringType &&
410410+ variants.unionRefs.length === 0 &&
411411+ isClosed(this.program, unionType))) {
412412 return this.createStringEnumDef(unionType, variants.stringLiterals, prop);
413413 }
414414···428428 return null;
429429 }
430430431431- // Case 4: Invalid string literal union (has literals but no string type)
431431+ // Case 4: Invalid string literal union (has literals but no string type and not @closed)
432432 if (variants.stringLiterals.length > 0 && !variants.hasStringType) {
433433 throw new Error(
434434- 'String literal unions must include "| string" to allow unknown values',
434434+ 'String literal unions must include "| string" to allow unknown values. ' +
435435+ 'Use @closed decorator if this is intentionally a fixed enum.',
435436 );
436437 }
437438···487488 stringLiterals: string[],
488489 prop?: ModelProperty,
489490 ): LexiconDefinition {
490490- const primitive: any = { type: "string", knownValues: stringLiterals };
491491+ // Use "enum" for @closed unions, "knownValues" for open unions
492492+ const isClosedUnion = isClosed(this.program, unionType);
493493+ const primitive: any = {
494494+ type: "string",
495495+ [isClosedUnion ? "enum" : "knownValues"]: stringLiterals,
496496+ };
491497492498 // Apply constraints
493499 const maxLength = getMaxLength(this.program, unionType);
···885891 return this.addDescription(this.createBlobDef(model), propDesc);
886892 }
887893888888- // 2. Check for Closed<Union> template
889889- if (this.isClosedUnionTemplate(model)) {
890890- const unionArg = model.templateMapper?.args?.[0];
891891- if (unionArg && isType(unionArg) && unionArg.kind === "Union") {
892892- const unionDef = this.typeToLexiconDefinition(unionArg, prop);
893893- if (unionDef && unionDef.type === "union") {
894894- (unionDef as LexiconUnion).closed = true;
895895- return unionDef;
896896- }
897897- }
898898- throw new Error(
899899- "Closed<> template argument must be a union of model references",
900900- );
901901- }
902902-903903- // 3. Check for model reference (named models from other namespaces)
894894+ // 2. Check for model reference (named models from other namespaces)
904895 const modelRef = this.getModelReference(model);
905896 if (modelRef) {
906897 return this.addDescription({ type: "ref", ref: modelRef }, propDesc);
907898 }
908899909909- // 4. Check for array type
900900+ // 3. Check for array type
910901 if (isArrayModelType(this.program, model)) {
911902 const arrayDef = this.modelToLexiconArray(model, prop);
912903 if (!arrayDef) {
···917908 return this.addDescription(arrayDef, propDesc);
918909 }
919910920920- // 5. Inline object
911911+ // 4. Inline object
921912 return this.addDescription(this.modelToLexiconObject(model), propDesc);
922913 }
923914···11241115 const unionName = (union as any).name;
11251116 const namespace = (union as any).namespace;
11261117 if (!unionName || !namespace || namespace.name === "TypeSpec") return null;
11181118+11191119+ // If union is marked as @inline, don't create a reference - inline it instead
11201120+ if (isInline(this.program, union)) return null;
1127112111281122 const namespaceName = getNamespaceFullName(namespace);
11291123 if (!namespaceName) return null;
···44 @doc("Indicates that the 'swapCommit' parameter did not match current commit.")
55 model InvalidSwap {}
6677+ @closed
88+ @inline
99+ union WriteAction {
1010+ Create,
1111+ Update,
1212+ Delete,
1313+ }
1414+1515+ @closed
1616+ @inline
1717+ union WriteResult {
1818+ CreateResult,
1919+ UpdateResult,
2020+ DeleteResult,
2121+ }
2222+723 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.")
824 @procedure
925 @errors(InvalidSwap)
···1632 validate?: boolean;
17331834 @required
1919- writes: Closed<Create | Update | Delete>[];
3535+ writes: WriteAction[];
20362137 @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.")
2238 swapCommit?: cid;
2339 }): {
2440 commit?: com.atproto.repo.defs.CommitMeta;
2525- results?: Closed<CreateResult | UpdateResult | DeleteResult>[];
4141+ results?: WriteResult[];
2642 };
27432844 @doc("Operation which creates a new record.")