Skip to content

RFC: discriminated union for RelationshipAssociation targets #16

@cuibonobo

Description

@cuibonobo

Motivation

As RelationshipAssociation grows to support both internal (within-stack) and external (cross-protocol) references, the current flat optional fields create ambiguity — nothing prevents recordId and externalId from both being set, and TypeScript can't enforce that you've handled all reference types. A discriminated union makes invalid states unrepresentable and scales cleanly if a third target type is ever needed.


Proposed change

Replace the flat optional fields on RelationshipAssociation with a target discriminated union:

// Before
export type RelationshipAssociation = {
  kind: 'relationship';
  label: string;
  recordId?: RecordId;
  stackUrl?: string;
  externalId?: string;
  externalNs?: string;
};

// After
type InternalTarget = {
  scope: 'internal';
  recordId: RecordId;
  stackUrl?: string;
};

type ExternalTarget = {
  scope: 'external';
  id: string;
  ns: string;
};

export type RelationshipAssociation = {
  kind: 'relationship';
  label: string;
  target: InternalTarget | ExternalTarget;
};

Usage examples

// Internal — link to a record in another stack
stack.associate(recordId, {
  kind: 'relationship',
  label: 'related-to',
  target: { scope: 'internal', recordId: 'abc123', stackUrl: 'https://alice.example.com/stack' }
});

// External — ATProto
stack.associate(recordId, {
  kind: 'relationship',
  label: 'reply-to',
  target: { scope: 'external', id: 'at://did:plc:abc123/app.bsky.feed.post/3k4', ns: 'atproto' }
});

// External — ActivityPub
stack.associate(recordId, {
  kind: 'relationship',
  label: 'mentions',
  target: { scope: 'external', id: 'https://mastodon.social/users/alice', ns: 'activitypub' }
});

// External — email
stack.associate(recordId, {
  kind: 'relationship',
  label: 'sent-to',
  target: { scope: 'external', id: 'alice@example.com', ns: 'email' }
});

Benefits

  • No invalid statesrecordId and externalId can never both be set
  • Compiler-enforced exhaustiveness — TypeScript will catch unhandled scope cases at switch/if sites
  • Extensible — a third scope (e.g. 'federated') can be added without touching existing code or consumers
  • Self-documenting — the type is unambiguous without needing documentation to explain field interactions

Storage note

Adapter authors can represent this however suits the backend — a JSON blob, a narrow nullable table, or a compact overloaded (ns, id) pair with an external flag. The public API shape and the storage representation are independent concerns.


Breaking change

Yes — this modifies the shape of RelationshipAssociation directly. Should be bundled with other breaking changes into a single semver major if any existing adapters or apps are consuming this type.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions