···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> {}
5151+5252+/**
3853 * AT Protocol format scalars.
3954 * These map to Lexicon string types with specific format constraints.
4055 */
+51-27
packages/emitter/src/emitter.ts
···405405 if (!unionDef) return;
406406407407 // Check if union contains only models (object types)
408408- // If so, wrap in array type (for Preferences pattern)
409409- // Otherwise, emit as-is (for string unions with knownValues)
410408 let hasModelTypes = false;
411409 for (const variant of union.variants.values()) {
412410 if (variant.type.kind === "Model") {
···417415418416 const description = getDoc(this.program, union);
419417418418+ // Closed model unions are NOT added to defs - they are only used inline
419419+ // This allows @closed unions like Write { Create | Update | Delete } to work
420420+ if (hasModelTypes && isClosed(this.program, union)) {
421421+ return;
422422+ }
423423+420424 if (hasModelTypes && unionDef.type === "union") {
421421- // Wrap union of models in array type
425425+ // Wrap open union of models in array type (for Preferences pattern)
422426 const arrayDef: any = {
423427 type: "array",
424428 items: unionDef,
···796800 if (prop.optional !== true) {
797801 // Field is required - check if it has the @required decorator
798802 if (!isRequired(this.program, prop)) {
799799- throw new Error(
800800- `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` +
803803+ this.program.reportDiagnostic({
804804+ code: "closed-open-union-inline",
805805+ message:
806806+ `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` +
801807 `In atproto, required fields are discouraged and must be intentional. ` +
802808 `Either add @required to the field or make it optional with "?".`,
803803- );
809809+ target: model,
810810+ severity: "error",
811811+ });
804812 }
805813 required.push(name);
806814 }
···11011109 // Check if this is a named union that should be referenced
11021110 // (but not if we're defining the union itself)
11031111 if (!isDefining) {
11041104- const unionRef = this.getUnionReference(unionType);
11051105- if (unionRef) {
11061106- const refDef: LexiconRef = {
11071107- type: "ref",
11081108- ref: unionRef,
11091109- };
11101110- if (prop) {
11111111- const propDesc = getDoc(this.program, prop);
11121112- if (propDesc) {
11131113- refDef.description = propDesc;
11121112+ // Check if this is a closed model union
11131113+ let hasModels = false;
11141114+ for (const variant of unionType.variants.values()) {
11151115+ if (variant.type.kind === "Model") {
11161116+ hasModels = true;
11171117+ break;
11181118+ }
11191119+ }
11201120+11211121+ // Skip references for @closed model unions - they should be inlined
11221122+ // All other named unions can be referenced
11231123+ if (!(hasModels && isClosed(this.program, unionType))) {
11241124+ const unionRef = this.getUnionReference(unionType);
11251125+ if (unionRef) {
11261126+ const refDef: LexiconRef = {
11271127+ type: "ref",
11281128+ ref: unionRef,
11291129+ };
11301130+ if (prop) {
11311131+ const propDesc = getDoc(this.program, prop);
11321132+ if (propDesc) {
11331133+ refDef.description = propDesc;
11341134+ }
11141135 }
11361136+ return refDef;
11151137 }
11161116- return refDef;
11171138 }
11181139 }
11191140···12031224 refs: unionRefs,
12041225 };
1205122612061206- // Check for @closed decorator on the property or union itself
12071207- if (prop && isClosed(this.program, prop)) {
12081208- unionDef.closed = true;
12091209- } else if (isClosed(this.program, unionType)) {
12101210- unionDef.closed = true;
12271227+ // Check for @closed decorator on the union itself (not on properties)
12281228+ if (isClosed(this.program, unionType)) {
12291229+ // Validate that @closed is not used on open unions (with unknown/never)
12301230+ if (hasUnknown) {
12311231+ this.program.reportDiagnostic({
12321232+ code: "closed-open-union",
12331233+ message:
12341234+ "@closed decorator cannot be used on open unions (unions containing 'unknown' or 'never'). Remove the @closed decorator or make the union closed by removing 'unknown'/'never'.",
12351235+ target: unionType,
12361236+ } as any);
12371237+ } else {
12381238+ unionDef.closed = true;
12391239+ }
12111240 }
1212124112131242 if (prop) {
···15171546 const itemDef = this.typeToLexiconDefinition(itemType);
1518154715191548 if (itemDef) {
15201520- // Check if the property has @closed decorator and the item is a union
15211521- if (prop && isClosed(this.program, prop) && itemDef.type === 'union') {
15221522- (itemDef as any).closed = true;
15231523- }
15241524-15251549 const arrayDef: LexiconArray = {
15261550 type: "array",
15271551 items: itemDef,
···347347 | app.bsky.feed.threadgate.FollowerRule
348348 | app.bsky.feed.threadgate.FollowingRule
349349 | app.bsky.feed.threadgate.ListRule
350350+ | unknown
350351 )[];
351352352353 @doc("Matches postgate record. List of rules defining who can embed this users posts. If value is an empty array or is undefined, no particular rules apply and anyone can embed.")
···2424 | app.bsky.embed.external.Main
2525 | app.bsky.embed.record.Main
2626 | app.bsky.embed.recordWithMedia.Main
2727+ | unknown
2728 );
28292930 @doc("Indicates human language of post primary text content.")
···44 @doc("Indicates that the 'swapCommit' parameter did not match current commit.")
55 model InvalidSwap {}
6677+ @closed
88+ union Write {
99+ Create,
1010+ Update,
1111+ Delete,
1212+ }
1313+1414+ @closed
1515+ union Result {
1616+ CreateResult,
1717+ UpdateResult,
1818+ DeleteResult,
1919+ }
2020+721 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.")
822 @procedure
923 @errors(InvalidSwap)
···1630 validate?: boolean;
17311832 @required
1919- @closed
2020- writes: (Create | Update | Delete)[];
3333+ writes: Write[];
21342235 @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.")
2336 swapCommit?: cid;
2437 }): {
2538 commit?: com.atproto.repo.defs.CommitMeta;
26392727- @closed
2828- results?: (CreateResult | UpdateResult | DeleteResult)[];
4040+ results?: Result[];
2941 };
30423143 @doc("Operation which creates a new record.")
···11+import "@tlex/emitter";
22+33+namespace test.defs;
44+55+model A {}
66+model B {}
77+model C {}
88+99+// Named open union: separate declaration + has unknown
1010+union OpenNamed {
1111+ A,
1212+ B,
1313+ C,
1414+ unknown
1515+}
1616+1717+// Named closed union: separate declaration + no unknown + @closed required
1818+@closed
1919+union ClosedNamed {
2020+ A,
2121+ B,
2222+ C
2323+}
2424+2525+model Examples {
2626+ openNamedField?: OpenNamed;
2727+2828+ closedNamedField?: ClosedNamed;
2929+3030+ openInlineField?: A | B | C | unknown;
3131+3232+ closedInlineField?: Closed<A | B | C>;
3333+3434+ openNamedFieldArray?: OpenNamed[];
3535+3636+ closedNamedFieldArray?: ClosedNamed[];
3737+3838+ openInlineFieldArray?: (A | B | C | unknown)[];
3939+4040+ closedInlineFieldArray?: (Closed<A | B | C>)[];
4141+}
···1010 validate = _validate
1111const id = 'app.example.follow'
12121313+/** A follow relationship */
1314export interface Record {
1415 $type: 'app.example.follow'
1516 /** DID of the account being followed */
+1
packages/example/src/types/app/example/like.ts
···1111 validate = _validate
1212const id = 'app.example.like'
13131414+/** A like on a post */
1415export interface Record {
1516 $type: 'app.example.like'
1617 subject: AppExampleDefs.PostRef
+1
packages/example/src/types/app/example/post.ts
···1111 validate = _validate
1212const id = 'app.example.post'
13131414+/** A post in the feed */
1415export interface Record {
1516 $type: 'app.example.post'
1617 /** Post text content */
+1
packages/example/src/types/app/example/profile.ts
···1010 validate = _validate
1111const id = 'app.example.profile'
12121313+/** User profile information */
1314export interface Record {
1415 $type: 'app.example.profile'
1516 /** Display name */
+1
packages/example/src/types/app/example/repost.ts
···1111 validate = _validate
1212const id = 'app.example.repost'
13131414+/** A repost of another post */
1415export interface Record {
1516 $type: 'app.example.repost'
1617 subject: AppExampleDefs.PostRef