Last active
September 2, 2025 18:05
-
-
Save melodymorgan/3f445024728678f935e052eedd6c8253 to your computer and use it in GitHub Desktop.
keep expansions optional in your base type but also allow a stricter generic type when you know which expansions are present (e.g., in your client SDK)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ---------- Base Types ---------- | |
| interface Transaction { | |
| id: string; | |
| amount: number; | |
| } | |
| interface Document { | |
| id: string; | |
| title: string; | |
| } | |
| interface Participant { | |
| id: string; | |
| name: string; | |
| } | |
| interface Process { | |
| id: string; | |
| step: string; | |
| } | |
| interface Requirement { | |
| id: string; | |
| description: string; | |
| } | |
| // ---------- Expansions Map ---------- | |
| type ExpansionsMap = { | |
| documents: Document[]; | |
| participants: Participant[]; | |
| processes: Process[]; | |
| requirements: Requirement[]; | |
| }; | |
| // ---------- Generate EXPAND_KEYS automatically ---------- | |
| export const EXPAND_KEYS = Object.freeze( | |
| Object.keys({} as ExpansionsMap).reduce((acc, key) => { | |
| acc[key as keyof ExpansionsMap] = key; | |
| return acc; | |
| }, {} as Record<keyof ExpansionsMap, keyof ExpansionsMap>) | |
| ); | |
| export type ExpandKey = keyof typeof EXPAND_KEYS; | |
| // Base response: transaction always present, expansions optional | |
| type TransactionResponseBase = { | |
| transaction: Transaction; | |
| } & Partial<ExpansionsMap>; | |
| // ---------- Utility: Pretty Print ---------- | |
| type Expand<T> = { [K in keyof T]: T[K] } & {}; | |
| // ---------- Domain-Specific Expand ---------- | |
| type ExpandKeys< | |
| Base, | |
| Keys extends keyof ExpansionsMap | |
| > = Expand< | |
| Base & { | |
| [K in Keys]-?: { [P in K]: ExpansionsMap[P] } | |
| }[Keys] extends infer O | |
| ? { [K in keyof O]: O[K] } | |
| : never | |
| >; | |
| // ---------- SDK Function ---------- | |
| async function getTransaction< | |
| T extends ExpandKey[] = [] | |
| >( | |
| opts?: { expand?: T } | |
| ): Promise<ExpandKeys<TransactionResponseBase, T[number]>> { | |
| // Typed mock base | |
| const base: TransactionResponseBase = { | |
| transaction: { id: "txn_1", amount: 100 }, | |
| documents: [{ id: "doc_1", title: "Contract" }], | |
| participants: [{ id: "p_1", name: "Alice" }], | |
| processes: [{ id: "proc_1", step: "review" }], | |
| requirements: [{ id: "req_1", description: "Signed NDA" }], | |
| }; | |
| if (!opts?.expand) { | |
| return { transaction: base.transaction } as ExpandKeys< | |
| TransactionResponseBase, | |
| T[number] | |
| >; | |
| } | |
| const picked: Pick<ExpansionsMap, T[number]> = {} as Pick< | |
| ExpansionsMap, | |
| T[number] | |
| >; | |
| for (const key of opts.expand) { | |
| picked[key] = base[key]!; | |
| } | |
| return { transaction: base.transaction, ...picked } as ExpandKeys< | |
| TransactionResponseBase, | |
| T[number] | |
| >; | |
| } | |
| // ---------- Example Usage ---------- | |
| async function runExamples() { | |
| // Expand using generated constants | |
| const r = await getTransaction({ | |
| expand: [EXPAND_KEYS.documents, EXPAND_KEYS.participants], | |
| }); | |
| r.documents[0].title; // ✅ required | |
| r.participants[0].name; // ✅ required | |
| r.processes; // ❌ TS error | |
| } | |
| runExamples(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // refine this so EXPAND_KEYS.documents is typed literally as "documents", instead of just string | |
| // ---------- Expansions Map ---------- | |
| type ExpansionsMap = { | |
| documents: { id: string; title: string }[]; | |
| participants: { id: string; name: string }[]; | |
| processes: { id: string; step: string }[]; | |
| requirements: { id: string; description: string }[]; | |
| }; | |
| // ---------- Generate EXPAND_KEYS with literal types ---------- | |
| export const EXPAND_KEYS = Object.freeze( | |
| Object.keys({} as ExpansionsMap).reduce((acc, key) => { | |
| acc[key as keyof ExpansionsMap] = key; | |
| return acc; | |
| }, {} as Record<keyof ExpansionsMap, keyof ExpansionsMap>) | |
| ) as { | |
| readonly [K in keyof ExpansionsMap]: K; | |
| }; | |
| // Type = "documents" | "participants" | "processes" | "requirements" | |
| export type ExpandKey = keyof typeof EXPAND_KEYS; | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ---------- Expansions Map ---------- | |
| type ExpansionsMap = { | |
| documents: { id: string; title: string }[]; | |
| participants: { id: string; name: string }[]; | |
| processes: { id: string; step: string }[]; | |
| requirements: { id: string; description: string }[]; | |
| }; | |
| // ---------- Generate EXPAND_KEYS with literal types ---------- | |
| export const EXPAND_KEYS = { | |
| documents: "documents", | |
| participants: "participants", | |
| processes: "processes", | |
| requirements: "requirements", | |
| } as const; | |
| export type ExpandKey = keyof typeof EXPAND_KEYS; | |
| // ---------- Base Response ---------- | |
| type TransactionResponseBase = { | |
| transaction: { id: string; amount: number }; | |
| }; | |
| // ---------- Utility: attach expansions ---------- | |
| type ExpandKeys<Base, K extends keyof ExpansionsMap> = | |
| Base & { [P in K]: ExpansionsMap[P] }; | |
| // ---------- SDK function with literal preservation ---------- | |
| async function getTransaction<const T extends readonly ExpandKey[]>( | |
| opts?: { expand?: T } | |
| ): Promise< | |
| T extends readonly [] | |
| ? TransactionResponseBase | |
| : ExpandKeys<TransactionResponseBase, T[number]> | |
| > { | |
| const base: TransactionResponseBase & ExpansionsMap = { | |
| transaction: { id: "txn_1", amount: 100 }, | |
| documents: [{ id: "doc_1", title: "Contract" }], | |
| participants: [{ id: "p_1", name: "Alice" }], | |
| processes: [{ id: "proc_1", step: "review" }], | |
| requirements: [{ id: "req_1", description: "Signed NDA" }], | |
| }; | |
| if (!opts?.expand?.length) { | |
| return { transaction: base.transaction } as TransactionResponseBase; | |
| } | |
| // Pick expansions dynamically | |
| const picked = Object.fromEntries( | |
| opts.expand.map(k => [k, base[k]]) | |
| ) as Pick<ExpansionsMap, T[number]>; | |
| return { transaction: base.transaction, ...picked } as ExpandKeys< | |
| TransactionResponseBase, | |
| T[number] | |
| >; | |
| } | |
| // usage examples | |
| // No expansions → only transaction | |
| const r1 = await getTransaction(); | |
| // ^? { transaction: { id: string; amount: number } } | |
| // Expand documents only → narrowed type includes only documents | |
| const r2 = await getTransaction({ expand: [EXPAND_KEYS.documents] }); | |
| // ^? { transaction: {...}; documents: { id: string; title: string }[] } | |
| // Expand documents + participants → narrowed type includes both | |
| const r3 = await getTransaction({ | |
| expand: [EXPAND_KEYS.documents, EXPAND_KEYS.participants], | |
| }); | |
| // ^? { transaction: {...}; documents: ...; participants: ... } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment