···35}
3637/**
00000000000000038 * AT Protocol format scalars.
39 * These map to Lexicon string types with specific format constraints.
40 */
···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> {}
51+52+/**
53 * AT Protocol format scalars.
54 * These map to Lexicon string types with specific format constraints.
55 */
+51-27
packages/emitter/src/emitter.ts
···405 if (!unionDef) return;
406407 // Check if union contains only models (object types)
408- // If so, wrap in array type (for Preferences pattern)
409- // Otherwise, emit as-is (for string unions with knownValues)
410 let hasModelTypes = false;
411 for (const variant of union.variants.values()) {
412 if (variant.type.kind === "Model") {
···417418 const description = getDoc(this.program, union);
419000000420 if (hasModelTypes && unionDef.type === "union") {
421- // Wrap union of models in array type
422 const arrayDef: any = {
423 type: "array",
424 items: unionDef,
···796 if (prop.optional !== true) {
797 // Field is required - check if it has the @required decorator
798 if (!isRequired(this.program, prop)) {
799- throw new Error(
800- `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` +
00801 `In atproto, required fields are discouraged and must be intentional. ` +
802 `Either add @required to the field or make it optional with "?".`,
803- );
00804 }
805 required.push(name);
806 }
···1101 // Check if this is a named union that should be referenced
1102 // (but not if we're defining the union itself)
1103 if (!isDefining) {
1104- const unionRef = this.getUnionReference(unionType);
1105- if (unionRef) {
1106- const refDef: LexiconRef = {
1107- type: "ref",
1108- ref: unionRef,
1109- };
1110- if (prop) {
1111- const propDesc = getDoc(this.program, prop);
1112- if (propDesc) {
1113- refDef.description = propDesc;
00000000000001114 }
01115 }
1116- return refDef;
1117 }
1118 }
1119···1203 refs: unionRefs,
1204 };
12051206- // Check for @closed decorator on the property or union itself
1207- if (prop && isClosed(this.program, prop)) {
1208- unionDef.closed = true;
1209- } else if (isClosed(this.program, unionType)) {
1210- unionDef.closed = true;
000000001211 }
12121213 if (prop) {
···1517 const itemDef = this.typeToLexiconDefinition(itemType);
15181519 if (itemDef) {
1520- // Check if the property has @closed decorator and the item is a union
1521- if (prop && isClosed(this.program, prop) && itemDef.type === 'union') {
1522- (itemDef as any).closed = true;
1523- }
1524-1525 const arrayDef: LexiconArray = {
1526 type: "array",
1527 items: itemDef,
···405 if (!unionDef) return;
406407 // Check if union contains only models (object types)
00408 let hasModelTypes = false;
409 for (const variant of union.variants.values()) {
410 if (variant.type.kind === "Model") {
···415416 const description = getDoc(this.program, union);
417418+ // Closed model unions are NOT added to defs - they are only used inline
419+ // This allows @closed unions like Write { Create | Update | Delete } to work
420+ if (hasModelTypes && isClosed(this.program, union)) {
421+ return;
422+ }
423+424 if (hasModelTypes && unionDef.type === "union") {
425+ // Wrap open union of models in array type (for Preferences pattern)
426 const arrayDef: any = {
427 type: "array",
428 items: unionDef,
···800 if (prop.optional !== true) {
801 // Field is required - check if it has the @required decorator
802 if (!isRequired(this.program, prop)) {
803+ this.program.reportDiagnostic({
804+ code: "closed-open-union-inline",
805+ message:
806+ `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` +
807 `In atproto, required fields are discouraged and must be intentional. ` +
808 `Either add @required to the field or make it optional with "?".`,
809+ target: model,
810+ severity: "error",
811+ });
812 }
813 required.push(name);
814 }
···1109 // Check if this is a named union that should be referenced
1110 // (but not if we're defining the union itself)
1111 if (!isDefining) {
1112+ // Check if this is a closed model union
1113+ let hasModels = false;
1114+ for (const variant of unionType.variants.values()) {
1115+ if (variant.type.kind === "Model") {
1116+ hasModels = true;
1117+ break;
1118+ }
1119+ }
1120+1121+ // Skip references for @closed model unions - they should be inlined
1122+ // All other named unions can be referenced
1123+ if (!(hasModels && isClosed(this.program, unionType))) {
1124+ const unionRef = this.getUnionReference(unionType);
1125+ if (unionRef) {
1126+ const refDef: LexiconRef = {
1127+ type: "ref",
1128+ ref: unionRef,
1129+ };
1130+ if (prop) {
1131+ const propDesc = getDoc(this.program, prop);
1132+ if (propDesc) {
1133+ refDef.description = propDesc;
1134+ }
1135 }
1136+ return refDef;
1137 }
01138 }
1139 }
1140···1224 refs: unionRefs,
1225 };
12261227+ // Check for @closed decorator on the union itself (not on properties)
1228+ if (isClosed(this.program, unionType)) {
1229+ // Validate that @closed is not used on open unions (with unknown/never)
1230+ if (hasUnknown) {
1231+ this.program.reportDiagnostic({
1232+ code: "closed-open-union",
1233+ message:
1234+ "@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'.",
1235+ target: unionType,
1236+ } as any);
1237+ } else {
1238+ unionDef.closed = true;
1239+ }
1240 }
12411242 if (prop) {
···1546 const itemDef = this.typeToLexiconDefinition(itemType);
15471548 if (itemDef) {
000001549 const arrayDef: LexiconArray = {
1550 type: "array",
1551 items: itemDef,
···347 | app.bsky.feed.threadgate.FollowerRule
348 | app.bsky.feed.threadgate.FollowingRule
349 | app.bsky.feed.threadgate.ListRule
0350 )[];
351352 @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.")
···347 | app.bsky.feed.threadgate.FollowerRule
348 | app.bsky.feed.threadgate.FollowingRule
349 | app.bsky.feed.threadgate.ListRule
350+ | unknown
351 )[];
352353 @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.")
···13 | app.bsky.embed.external.View
14 | app.bsky.embed.record.View
15 | app.bsky.embed.recordWithMedia.View
016 );
1718 bookmarkCount?: integer;
···48 @required post: PostView;
4950 reply?: ReplyRef;
51- reason?: (ReasonRepost | ReasonPin);
5253 @doc("Context provided by feed generator that may be passed back alongside interactions.")
54 @maxLength(2000)
···60 }
6162 model ReplyRef {
63- @required root: (PostView | NotFoundPost | BlockedPost);
64- @required parent: (PostView | NotFoundPost | BlockedPost);
6566 @doc("When parent is a reply to another post, this is the author of that post.")
67 grandparentAuthor?: app.bsky.actor.defs.ProfileViewBasic;
···144 model SkeletonFeedPost {
145 @required post: atUri;
146147- reason?: (SkeletonReasonRepost | SkeletonReasonPin);
148149 @doc("Context that will be passed through to client and may be passed to feed generator back alongside interactions.")
150 @maxLength(2000)
···13 | app.bsky.embed.external.View
14 | app.bsky.embed.record.View
15 | app.bsky.embed.recordWithMedia.View
16+ | unknown
17 );
1819 bookmarkCount?: integer;
···49 @required post: PostView;
5051 reply?: ReplyRef;
52+ reason?: (ReasonRepost | ReasonPin | unknown);
5354 @doc("Context provided by feed generator that may be passed back alongside interactions.")
55 @maxLength(2000)
···61 }
6263 model ReplyRef {
64+ @required root: (PostView | NotFoundPost | BlockedPost | unknown);
65+ @required parent: (PostView | NotFoundPost | BlockedPost | unknown);
6667 @doc("When parent is a reply to another post, this is the author of that post.")
68 grandparentAuthor?: app.bsky.actor.defs.ProfileViewBasic;
···145 model SkeletonFeedPost {
146 @required post: atUri;
147148+ reason?: (SkeletonReasonRepost | SkeletonReasonPin | unknown);
149150 @doc("Context that will be passed through to client and may be passed to feed generator back alongside interactions.")
151 @maxLength(2000)
···24 | app.bsky.embed.external.Main
25 | app.bsky.embed.record.Main
26 | app.bsky.embed.recordWithMedia.Main
027 );
2829 @doc("Indicates human language of post primary text content.")
···24 | app.bsky.embed.external.Main
25 | app.bsky.embed.record.Main
26 | app.bsky.embed.recordWithMedia.Main
27+ | unknown
28 );
2930 @doc("Indicates human language of post primary text content.")
···4 @doc("Indicates that the 'swapCommit' parameter did not match current commit.")
5 model InvalidSwap {}
6000000000000007 @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- @closed
20- writes: (Create | Update | Delete)[];
2122 @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.")
23 swapCommit?: cid;
24 }): {
25 commit?: com.atproto.repo.defs.CommitMeta;
2627- @closed
28- results?: (CreateResult | UpdateResult | DeleteResult)[];
29 };
3031 @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+ union Write {
9+ Create,
10+ Update,
11+ Delete,
12+ }
13+14+ @closed
15+ union Result {
16+ CreateResult,
17+ UpdateResult,
18+ DeleteResult,
19+ }
20+21 @doc("Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.")
22 @procedure
23 @errors(InvalidSwap)
···30 validate?: boolean;
3132 @required
33+ writes: Write[];
03435 @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.")
36 swapCommit?: cid;
37 }): {
38 commit?: com.atproto.repo.defs.CommitMeta;
3940+ results?: Result[];
041 };
4243 @doc("Operation which creates a new record.")
···1+import "@tlex/emitter";
2+3+namespace test.defs;
4+5+model A {}
6+model B {}
7+model C {}
8+9+// Named open union: separate declaration + has unknown
10+union OpenNamed {
11+ A,
12+ B,
13+ C,
14+ unknown
15+}
16+17+// Named closed union: separate declaration + no unknown + @closed required
18+@closed
19+union ClosedNamed {
20+ A,
21+ B,
22+ C
23+}
24+25+model Examples {
26+ openNamedField?: OpenNamed;
27+28+ closedNamedField?: ClosedNamed;
29+30+ openInlineField?: A | B | C | unknown;
31+32+ closedInlineField?: Closed<A | B | C>;
33+34+ openNamedFieldArray?: OpenNamed[];
35+36+ closedNamedFieldArray?: ClosedNamed[];
37+38+ openInlineFieldArray?: (A | B | C | unknown)[];
39+40+ closedInlineFieldArray?: (Closed<A | B | C>)[];
41+}
···24 @required post: PostView;
2526 @doc("Parent post (may be not found or blocked)")
27- parent?: PostView | NotFoundPost | BlockedPost;
28 }
29}
···24 @required post: PostView;
2526 @doc("Parent post (may be not found or blocked)")
27+ parent?: PostView | NotFoundPost | BlockedPost | unknown;
28 }
29}
···10 validate = _validate
11const id = 'app.example.follow'
12013export interface Record {
14 $type: 'app.example.follow'
15 /** DID of the account being followed */
···10 validate = _validate
11const id = 'app.example.follow'
1213+/** A follow relationship */
14export interface Record {
15 $type: 'app.example.follow'
16 /** DID of the account being followed */
+1
packages/example/src/types/app/example/like.ts
···11 validate = _validate
12const id = 'app.example.like'
13014export interface Record {
15 $type: 'app.example.like'
16 subject: AppExampleDefs.PostRef
···11 validate = _validate
12const id = 'app.example.like'
1314+/** A like on a post */
15export interface Record {
16 $type: 'app.example.like'
17 subject: AppExampleDefs.PostRef
+1
packages/example/src/types/app/example/post.ts
···11 validate = _validate
12const id = 'app.example.post'
13014export interface Record {
15 $type: 'app.example.post'
16 /** Post text content */
···11 validate = _validate
12const id = 'app.example.post'
1314+/** A post in the feed */
15export interface Record {
16 $type: 'app.example.post'
17 /** Post text content */
+1
packages/example/src/types/app/example/profile.ts
···10 validate = _validate
11const id = 'app.example.profile'
12013export interface Record {
14 $type: 'app.example.profile'
15 /** Display name */
···10 validate = _validate
11const id = 'app.example.profile'
1213+/** User profile information */
14export interface Record {
15 $type: 'app.example.profile'
16 /** Display name */
+1
packages/example/src/types/app/example/repost.ts
···11 validate = _validate
12const id = 'app.example.repost'
13014export interface Record {
15 $type: 'app.example.repost'
16 subject: AppExampleDefs.PostRef
···11 validate = _validate
12const id = 'app.example.repost'
1314+/** A repost of another post */
15export interface Record {
16 $type: 'app.example.repost'
17 subject: AppExampleDefs.PostRef