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 states —
recordId 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.
Motivation
As
RelationshipAssociationgrows to support both internal (within-stack) and external (cross-protocol) references, the current flat optional fields create ambiguity — nothing preventsrecordIdandexternalIdfrom 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
RelationshipAssociationwith atargetdiscriminated union:Usage examples
Benefits
recordIdandexternalIdcan never both be setscopecases at switch/if sites'federated') can be added without touching existing code or consumersStorage 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 anexternalflag. The public API shape and the storage representation are independent concerns.Breaking change
Yes — this modifies the shape of
RelationshipAssociationdirectly. Should be bundled with other breaking changes into a single semver major if any existing adapters or apps are consuming this type.