···514514515515## Unions
516516517517-### Union of Object Types
517517+### Overview
518518+519519+In ATProto, unions are **critical** for backwards compatibility. The TypeSpec syntax enforces intentional design:
520520+521521+- **Open unions** (extensible): Use `union { A, B, unknown }` or `(A | B | unknown)`
522522+- **Closed unions** (fixed set): Use `@closed union { A, B }` (named) or `Closed<A | B>` (inline)
523523+- **Not allowed**: Plain `union { A, B }` or `(A | B)` without explicit open/closed marker
524524+525525+This prevents accidentally creating closed unions where extensibility matters.
526526+527527+**Important:** The `@closed` decorator can ONLY be used on `union` declarations, not on properties.
528528+529529+### Open Unions (Extensible)
530530+531531+**Pattern:** Include `unknown` to allow future variants
518532519519-**Pattern:** Use TypeSpec union syntax `(Type1 | Type2 | ...)`
533533+Open unions are the **default pattern in ATProto** - they allow adding new variants without breaking existing clients.
520534521521-**TypeSpec:**
535535+**Inline union syntax:**
536536+```typespec
537537+model Post {
538538+ @maxItems(5)
539539+ embeddingRules?: (DisableRule | unknown)[];
540540+541541+ labels?: (com.atproto.label.defs.SelfLabels | unknown);
542542+}
543543+544544+model DisableRule {}
545545+```
546546+547547+**Alternative union syntax:**
522548```typespec
523549model Main {
524524- @required features: (Mention | Link | Tag)[];
550550+ @required features: union { Mention, Link, Tag, unknown }[];
525551}
526552527553model Mention {
···540566**JSON:**
541567```json
542568{
543543- "features": {
569569+ "embeddingRules": {
544570 "type": "array",
571571+ "maxLength": 5,
545572 "items": {
546573 "type": "union",
547547- "refs": ["#mention", "#link", "#tag"]
574574+ "refs": ["#disableRule"]
548575 }
549549- }
550550-}
551551-```
552552-553553-**TypeScript Output:**
554554-```typescript
555555-features: ($Typed<Mention> | $Typed<Link> | $Typed<Tag> | { $type: string })[]
556556-```
557557-558558-Note: Each union variant becomes a separate def and gets a `$type` discriminator in TypeScript.
559559-560560-### Open Union (Extensible Union)
561561-562562-**Pattern:** Include `unknown` in union to allow future extension with unknown types
563563-564564-**TypeSpec:**
565565-```typespec
566566-model Main {
567567- @maxItems(5)
568568- embeddingRules?: (DisableRule | unknown)[];
569569-}
570570-571571-model DisableRule {}
572572-```
573573-574574-**JSON:**
575575-```json
576576-{
577577- "embeddingRules": {
576576+ },
577577+ "labels": {
578578+ "type": "union",
579579+ "refs": ["com.atproto.label.defs#selfLabels"]
580580+ },
581581+ "features": {
578582 "type": "array",
579579- "maxLength": 5,
580583 "items": {
581584 "type": "union",
582582- "refs": ["#disableRule"]
585585+ "refs": ["#mention", "#link", "#tag"]
583586 }
584587 }
585588}
···588591**TypeScript Output:**
589592```typescript
590593embeddingRules?: ($Typed<DisableRule> | { $type: string })[]
594594+labels?: $Typed<SelfLabels> | { $type: string }
595595+features: ($Typed<Mention> | $Typed<Link> | $Typed<Tag> | { $type: string })[]
591596```
592597593593-The `unknown` type is omitted from the refs but signals the union is open to extension, allowing `{ $type: string }` in TypeScript.
598598+**Note:** The `unknown` type is omitted from JSON `refs` but signals extensibility, producing `{ $type: string }` in TypeScript to accept future variants.
594599595595-### Closed Reference vs Open Union
600600+### Closed Unions (Fixed Set)
596601597597-**Pattern:** Choose based on extensibility requirements
602602+**Pattern:** Use `@closed` on named union declarations, or `Closed<>` template for inline unions
598603599599-In ATProto, the distinction between a closed reference and an open (extensible) union is ESSENTIAL for backwards compatibility:
604604+Closed unions reject unknown variants - use only when the set is guaranteed never to change.
600605601601-- **Closed reference (`ref`)**: The type is fixed and will never accept additional variants
602602-- **Open union (`union`)**: The type can be extended with new variants in future versions
603603-604604-**Closed Reference (Not Extensible):**
605605-606606-Use when referencing a single type that will never accept other variants.
607607-608608-**TypeSpec:**
606606+**Named union with `@closed` decorator:**
609607```typespec
610610-model StatusView {
611611- embed?: app.bsky.embed.external.View; // Closed ref - exactly this type, no union
608608+@closed
609609+union WriteTypes {
610610+ Create,
611611+ Update,
612612+ Delete,
612613}
613613-```
614614615615-**JSON:**
616616-```json
617617-{
618618- "embed": {
619619- "type": "ref",
620620- "ref": "app.bsky.embed.external#view"
621621- }
615615+model Main {
616616+ @required writes: WriteTypes[];
622617}
623618```
624619625625-**Closed Union (Not Extensible):**
626626-627627-Use `@closed` decorator when you have multiple variants but the set is fixed and will never be extended.
628628-629629-**TypeSpec:**
620620+**Inline union with `Closed<>` template:**
630621```typespec
631622model Main {
632632- @required
633633- @closed
634634- writes: (Create | Update | Delete)[]; // Closed union - exactly these 3 types
623623+ @required writes: Closed<Create | Update | Delete>[];
635624}
636625```
637626···649638}
650639```
651640652652-**Open Union (Extensible):**
653653-654654-Use `| unknown` when the type might accept additional variants in future versions. This is the **default pattern in ATProto**.
655655-656656-**TypeSpec:**
657657-```typespec
658658-model Post {
659659- labels?: (com.atproto.label.defs.SelfLabels | unknown); // Open union - can add more label types later
660660-}
641641+**TypeScript Output:**
642642+```typescript
643643+writes: ($Typed<Create> | $Typed<Update> | $Typed<Delete>)[]
661644```
662645663663-**JSON:**
664664-```json
665665-{
666666- "labels": {
667667- "type": "union",
668668- "refs": ["com.atproto.label.defs#selfLabels"]
669669- }
670670-}
671671-```
646646+**Important:** Closed unions do NOT include `{ $type: string }` - they only accept the exact listed types.
672647673673-**Note:** The `unknown` type is omitted from the JSON `refs` array but signals that the union is open to extension.
648648+### Single Type Reference (Not a Union)
674649675675-### Empty Union Variants
650650+**Pattern:** Reference a model directly without union syntax
676651677677-**Pattern:** Empty models for union variants that have no properties
652652+When you have exactly one type and don't need extensibility:
678653679654**TypeSpec:**
680655```typespec
681681-model Main {
682682- allow?: (MentionRule | FollowerRule)[];
656656+model StatusView {
657657+ embed?: app.bsky.embed.external.View; // Single ref, not a union
683658}
684684-685685-@doc("Allow replies from actors mentioned in your post.")
686686-model MentionRule {}
687687-688688-@doc("Allow replies from actors who follow you.")
689689-model FollowerRule {}
690659```
691660692661**JSON:**
693662```json
694663{
695695- "mentionRule": {
696696- "type": "object",
697697- "description": "Allow replies from actors mentioned in your post.",
698698- "properties": {}
664664+ "embed": {
665665+ "type": "ref",
666666+ "ref": "app.bsky.embed.external#view"
699667 }
700668}
701669```
702670703703-### Union Def (Preferences Pattern)
671671+This is NOT a union - it's a plain reference. Use this when you have a single type that won't change.
672672+673673+### Named Union Defs
704674705705-**Pattern:** Define a top-level union of models as a named def
675675+**Pattern:** Define a top-level union type for reuse
706676707677**TypeSpec:**
708678```typespec
···715685 PersonalDetailsPref,
716686 FeedViewPref,
717687 ThreadViewPref,
688688+ unknown,
718689 }
719690720691 model AdultContentPref {
···748719}
749720```
750721751751-**When to use:** When you want to define a reusable union type that represents an array of variant models. The union automatically gets wrapped in an array type at the def level.
722722+**When to use:** When defining a reusable union that will be referenced elsewhere. The union automatically wraps in an array at the def level. Always include `unknown` for open unions.
723723+724724+### Empty Union Variants
725725+726726+**Pattern:** Empty models for variants with no properties
727727+728728+**TypeSpec:**
729729+```typespec
730730+model Main {
731731+ allow?: (MentionRule | FollowerRule | unknown)[];
732732+}
733733+734734+@doc("Allow replies from actors mentioned in your post.")
735735+model MentionRule {}
736736+737737+@doc("Allow replies from actors who follow you.")
738738+model FollowerRule {}
739739+```
740740+741741+**JSON:**
742742+```json
743743+{
744744+ "allow": {
745745+ "type": "array",
746746+ "items": {
747747+ "type": "union",
748748+ "refs": ["#mentionRule", "#followerRule"]
749749+ }
750750+ }
751751+},
752752+{
753753+ "mentionRule": {
754754+ "type": "object",
755755+ "description": "Allow replies from actors mentioned in your post.",
756756+ "properties": {}
757757+ },
758758+ "followerRule": {
759759+ "type": "object",
760760+ "description": "Allow replies from actors who follow you.",
761761+ "properties": {}
762762+ }
763763+}
764764+```
765765+766766+### Syntax Summary
767767+768768+| Pattern | Syntax | JSON `closed` field | Use Case |
769769+|---------|--------|---------------------|----------|
770770+| **Open union (inline)** | `(A \| B \| unknown)` | omitted (false) | Default - allows future variants |
771771+| **Open union (named)** | `union { A, B, unknown }` | omitted (false) | Named def, reusable |
772772+| **Closed union (named)** | `@closed union { A, B }` | `true` | Fixed set, named def |
773773+| **Closed union (inline)** | `Closed<A \| B>` | `true` | Fixed set, inline usage |
774774+| **Single reference** | `SomeType` | N/A (not a union) | Exactly one type, no variants |
775775+| **ERROR** | `(A \| B)` or `union { A, B }` | ❌ Not allowed | Must be explicit about open/closed |
752776753777---
754778