Type-Safe Adapter Registries with as const and satisfies
Learn how to replace hard-coded service calls with a type-safe adapter registry using TypeScript's as const, satisfies, and derived union types.
Hard-coding service calls is a trap. I recently refactored a dashboard that fetched positions from three external providers. The logic was scattered: the fetch function called each provider directly, the return type was a hand-written interface, and the UI component knew every provider by name. Adding a fourth provider meant touching four files and hoping I did not miss a type somewhere.
This post shows how I replaced that with a single adapter registry. The registry uses as const and satisfies to keep TypeScript in the loop, derives a literal union of keys from the array itself, and replaces the brittle interface with a Record that is impossible to mistype. The result is one file to edit when a new provider appears.
The problem with hard-coded calls
The original code looked like this inside a data-fetching function:
import { getAlphaPositions } from './alpha'
import { getBetaPositions } from './beta'
import { getGammaPositions } from './gamma'
export interface DashboardPositions {
alpha: AlphaPosition[]
beta: BetaPosition[]
gamma: GammaPosition[]
}
export async function getDashboardPositions(): Promise<DashboardPositions> {
const results = await Promise.all(
wallets.map(({ address }) =>
Promise.all([
getAlphaPositions(address),
getBetaPositions(address),
getGammaPositions(address),
]),
),
)
// ... accumulate into alpha, beta, gamma arrays
}
Every layer knew about every provider. The UI imported the interface and accessed positions.alpha, positions.beta, and positions.gamma directly. If one provider's API went down, Promise.all rejected and the entire page crashed.
A single registry
I started by defining what an adapter looks like:
export interface ServiceAdapter {
key: string
fetchPositions(address: string): Promise<Position[]>
}
Then I created the registry:
export const SERVICE_ADAPTERS = [
{ key: 'alpha' as const, fetchPositions: getAlphaPositions },
{ key: 'beta' as const, fetchPositions: getBetaPositions },
{ key: 'gamma' as const, fetchPositions: getGammaPositions },
] satisfies ServiceAdapter[]
Two things happen here. First, as const on each key tells TypeScript to treat the strings as literal types rather than widening them to string. Second, satisfies ServiceAdapter[] checks that every object matches the interface without widening the array's type. TypeScript keeps the narrow literal keys but still validates the shape.
Deriving keys from the array
Because the array is narrow, I can extract the key union directly from it:
export type ServiceKey = (typeof SERVICE_ADAPTERS)[number]['key']
// 'alpha' | 'beta' | 'gamma'
No manual union. No duplicated strings. If I add a fourth adapter to the array, ServiceKey updates automatically. This is the pattern's biggest win: the registry is the source of truth for both runtime and compile-time code.
Type-safe lookups with Record
Instead of a hand-written interface, the fetch function now returns:
export type DashboardPositions = Record<ServiceKey, Position[]>
This guarantees that every key in the registry has an entry in the result, and no extra keys can slip in. Inside the fetch function, I initialise the result object by iterating the registry:
const result = {} as DashboardPositions
for (const adapter of SERVICE_ADAPTERS) {
result[adapter.key] = []
}
Because adapter.key is typed as ServiceKey, the bracket lookup is checked. I cannot accidentally write result['unknown'] without a compiler error.
Resilience using Promise.allSettled
The old code used Promise.all, which meant one failing provider killed the whole request. I switched to Promise.allSettled:
const rawResults = await Promise.allSettled(
wallets.flatMap(({ address }) =>
SERVICE_ADAPTERS.map(async (adapter) => ({
key: adapter.key,
positions: await adapter.fetchPositions(address),
})),
),
)
for (const r of rawResults) {
if (r.status === 'fulfilled') {
result[r.value.key].push(...r.value.positions)
}
}
Now a flaky provider returns an empty array for its key, and the rest of the dashboard keeps working.
Wiring it up in a Next.js Server Component
The dashboard page is an async Server Component. After the refactor, it no longer needs to know provider names. It just checks whether any positions exist:
const hasPositions = Object.values(positions).some(
(p) => p.length > 0,
)
And renders the specific tables by key. The component still casts to narrower position types for the tables, but the coupling is gone.
Testing the registry
Unit tests become easier because the registry can be mocked. I swapped the real adapters for stubs in the test file:
vi.mock('@/lib/adapters', () => ({
SERVICE_ADAPTERS: [
{ key: 'proto1', fetchPositions: stubFetch1 },
{ key: 'proto2', fetchPositions: stubFetch2 },
],
}))
Tests now verify that the fetch function calls every adapter for every wallet, accumulates results, and survives a single adapter failure. They do not depend on real network calls or provider-specific types.
Conclusion
The adapter registry pattern is small but powerful. as const preserves literal types, satisfies validates shapes without widening, and (typeof array)[number]['key'] turns the runtime list into a compile-time union. Combined with Record, you get type-safe bracket lookups across the entire data layer. Add Promise.allSettled and the whole thing becomes resilient too.
Next time you find yourself adding another import and another property to an interface, consider a registry instead. One array. One source of truth.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.