An experimental TypeSpec syntax for Lexicon

up

+127 -103
+127 -103
SYNTAX.md
··· 514 514 515 515 ## Unions 516 516 517 - ### Union of Object Types 517 + ### Overview 518 + 519 + In ATProto, unions are **critical** for backwards compatibility. The TypeSpec syntax enforces intentional design: 520 + 521 + - **Open unions** (extensible): Use `union { A, B, unknown }` or `(A | B | unknown)` 522 + - **Closed unions** (fixed set): Use `@closed union { A, B }` (named) or `Closed<A | B>` (inline) 523 + - **Not allowed**: Plain `union { A, B }` or `(A | B)` without explicit open/closed marker 524 + 525 + This prevents accidentally creating closed unions where extensibility matters. 526 + 527 + **Important:** The `@closed` decorator can ONLY be used on `union` declarations, not on properties. 528 + 529 + ### Open Unions (Extensible) 530 + 531 + **Pattern:** Include `unknown` to allow future variants 518 532 519 - **Pattern:** Use TypeSpec union syntax `(Type1 | Type2 | ...)` 533 + Open unions are the **default pattern in ATProto** - they allow adding new variants without breaking existing clients. 520 534 521 - **TypeSpec:** 535 + **Inline union syntax:** 536 + ```typespec 537 + model Post { 538 + @maxItems(5) 539 + embeddingRules?: (DisableRule | unknown)[]; 540 + 541 + labels?: (com.atproto.label.defs.SelfLabels | unknown); 542 + } 543 + 544 + model DisableRule {} 545 + ``` 546 + 547 + **Alternative union syntax:** 522 548 ```typespec 523 549 model Main { 524 - @required features: (Mention | Link | Tag)[]; 550 + @required features: union { Mention, Link, Tag, unknown }[]; 525 551 } 526 552 527 553 model Mention { ··· 540 566 **JSON:** 541 567 ```json 542 568 { 543 - "features": { 569 + "embeddingRules": { 544 570 "type": "array", 571 + "maxLength": 5, 545 572 "items": { 546 573 "type": "union", 547 - "refs": ["#mention", "#link", "#tag"] 574 + "refs": ["#disableRule"] 548 575 } 549 - } 550 - } 551 - ``` 552 - 553 - **TypeScript Output:** 554 - ```typescript 555 - features: ($Typed<Mention> | $Typed<Link> | $Typed<Tag> | { $type: string })[] 556 - ``` 557 - 558 - Note: Each union variant becomes a separate def and gets a `$type` discriminator in TypeScript. 559 - 560 - ### Open Union (Extensible Union) 561 - 562 - **Pattern:** Include `unknown` in union to allow future extension with unknown types 563 - 564 - **TypeSpec:** 565 - ```typespec 566 - model Main { 567 - @maxItems(5) 568 - embeddingRules?: (DisableRule | unknown)[]; 569 - } 570 - 571 - model DisableRule {} 572 - ``` 573 - 574 - **JSON:** 575 - ```json 576 - { 577 - "embeddingRules": { 576 + }, 577 + "labels": { 578 + "type": "union", 579 + "refs": ["com.atproto.label.defs#selfLabels"] 580 + }, 581 + "features": { 578 582 "type": "array", 579 - "maxLength": 5, 580 583 "items": { 581 584 "type": "union", 582 - "refs": ["#disableRule"] 585 + "refs": ["#mention", "#link", "#tag"] 583 586 } 584 587 } 585 588 } ··· 588 591 **TypeScript Output:** 589 592 ```typescript 590 593 embeddingRules?: ($Typed<DisableRule> | { $type: string })[] 594 + labels?: $Typed<SelfLabels> | { $type: string } 595 + features: ($Typed<Mention> | $Typed<Link> | $Typed<Tag> | { $type: string })[] 591 596 ``` 592 597 593 - The `unknown` type is omitted from the refs but signals the union is open to extension, allowing `{ $type: string }` in TypeScript. 598 + **Note:** The `unknown` type is omitted from JSON `refs` but signals extensibility, producing `{ $type: string }` in TypeScript to accept future variants. 594 599 595 - ### Closed Reference vs Open Union 600 + ### Closed Unions (Fixed Set) 596 601 597 - **Pattern:** Choose based on extensibility requirements 602 + **Pattern:** Use `@closed` on named union declarations, or `Closed<>` template for inline unions 598 603 599 - In ATProto, the distinction between a closed reference and an open (extensible) union is ESSENTIAL for backwards compatibility: 604 + Closed unions reject unknown variants - use only when the set is guaranteed never to change. 600 605 601 - - **Closed reference (`ref`)**: The type is fixed and will never accept additional variants 602 - - **Open union (`union`)**: The type can be extended with new variants in future versions 603 - 604 - **Closed Reference (Not Extensible):** 605 - 606 - Use when referencing a single type that will never accept other variants. 607 - 608 - **TypeSpec:** 606 + **Named union with `@closed` decorator:** 609 607 ```typespec 610 - model StatusView { 611 - embed?: app.bsky.embed.external.View; // Closed ref - exactly this type, no union 608 + @closed 609 + union WriteTypes { 610 + Create, 611 + Update, 612 + Delete, 612 613 } 613 - ``` 614 614 615 - **JSON:** 616 - ```json 617 - { 618 - "embed": { 619 - "type": "ref", 620 - "ref": "app.bsky.embed.external#view" 621 - } 615 + model Main { 616 + @required writes: WriteTypes[]; 622 617 } 623 618 ``` 624 619 625 - **Closed Union (Not Extensible):** 626 - 627 - Use `@closed` decorator when you have multiple variants but the set is fixed and will never be extended. 628 - 629 - **TypeSpec:** 620 + **Inline union with `Closed<>` template:** 630 621 ```typespec 631 622 model Main { 632 - @required 633 - @closed 634 - writes: (Create | Update | Delete)[]; // Closed union - exactly these 3 types 623 + @required writes: Closed<Create | Update | Delete>[]; 635 624 } 636 625 ``` 637 626 ··· 649 638 } 650 639 ``` 651 640 652 - **Open Union (Extensible):** 653 - 654 - Use `| unknown` when the type might accept additional variants in future versions. This is the **default pattern in ATProto**. 655 - 656 - **TypeSpec:** 657 - ```typespec 658 - model Post { 659 - labels?: (com.atproto.label.defs.SelfLabels | unknown); // Open union - can add more label types later 660 - } 641 + **TypeScript Output:** 642 + ```typescript 643 + writes: ($Typed<Create> | $Typed<Update> | $Typed<Delete>)[] 661 644 ``` 662 645 663 - **JSON:** 664 - ```json 665 - { 666 - "labels": { 667 - "type": "union", 668 - "refs": ["com.atproto.label.defs#selfLabels"] 669 - } 670 - } 671 - ``` 646 + **Important:** Closed unions do NOT include `{ $type: string }` - they only accept the exact listed types. 672 647 673 - **Note:** The `unknown` type is omitted from the JSON `refs` array but signals that the union is open to extension. 648 + ### Single Type Reference (Not a Union) 674 649 675 - ### Empty Union Variants 650 + **Pattern:** Reference a model directly without union syntax 676 651 677 - **Pattern:** Empty models for union variants that have no properties 652 + When you have exactly one type and don't need extensibility: 678 653 679 654 **TypeSpec:** 680 655 ```typespec 681 - model Main { 682 - allow?: (MentionRule | FollowerRule)[]; 656 + model StatusView { 657 + embed?: app.bsky.embed.external.View; // Single ref, not a union 683 658 } 684 - 685 - @doc("Allow replies from actors mentioned in your post.") 686 - model MentionRule {} 687 - 688 - @doc("Allow replies from actors who follow you.") 689 - model FollowerRule {} 690 659 ``` 691 660 692 661 **JSON:** 693 662 ```json 694 663 { 695 - "mentionRule": { 696 - "type": "object", 697 - "description": "Allow replies from actors mentioned in your post.", 698 - "properties": {} 664 + "embed": { 665 + "type": "ref", 666 + "ref": "app.bsky.embed.external#view" 699 667 } 700 668 } 701 669 ``` 702 670 703 - ### Union Def (Preferences Pattern) 671 + This is NOT a union - it's a plain reference. Use this when you have a single type that won't change. 672 + 673 + ### Named Union Defs 704 674 705 - **Pattern:** Define a top-level union of models as a named def 675 + **Pattern:** Define a top-level union type for reuse 706 676 707 677 **TypeSpec:** 708 678 ```typespec ··· 715 685 PersonalDetailsPref, 716 686 FeedViewPref, 717 687 ThreadViewPref, 688 + unknown, 718 689 } 719 690 720 691 model AdultContentPref { ··· 748 719 } 749 720 ``` 750 721 751 - **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. 722 + **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. 723 + 724 + ### Empty Union Variants 725 + 726 + **Pattern:** Empty models for variants with no properties 727 + 728 + **TypeSpec:** 729 + ```typespec 730 + model Main { 731 + allow?: (MentionRule | FollowerRule | unknown)[]; 732 + } 733 + 734 + @doc("Allow replies from actors mentioned in your post.") 735 + model MentionRule {} 736 + 737 + @doc("Allow replies from actors who follow you.") 738 + model FollowerRule {} 739 + ``` 740 + 741 + **JSON:** 742 + ```json 743 + { 744 + "allow": { 745 + "type": "array", 746 + "items": { 747 + "type": "union", 748 + "refs": ["#mentionRule", "#followerRule"] 749 + } 750 + } 751 + }, 752 + { 753 + "mentionRule": { 754 + "type": "object", 755 + "description": "Allow replies from actors mentioned in your post.", 756 + "properties": {} 757 + }, 758 + "followerRule": { 759 + "type": "object", 760 + "description": "Allow replies from actors who follow you.", 761 + "properties": {} 762 + } 763 + } 764 + ``` 765 + 766 + ### Syntax Summary 767 + 768 + | Pattern | Syntax | JSON `closed` field | Use Case | 769 + |---------|--------|---------------------|----------| 770 + | **Open union (inline)** | `(A \| B \| unknown)` | omitted (false) | Default - allows future variants | 771 + | **Open union (named)** | `union { A, B, unknown }` | omitted (false) | Named def, reusable | 772 + | **Closed union (named)** | `@closed union { A, B }` | `true` | Fixed set, named def | 773 + | **Closed union (inline)** | `Closed<A \| B>` | `true` | Fixed set, inline usage | 774 + | **Single reference** | `SomeType` | N/A (not a union) | Exactly one type, no variants | 775 + | **ERROR** | `(A \| B)` or `union { A, B }` | ❌ Not allowed | Must be explicit about open/closed | 752 776 753 777 --- 754 778