Configuration seams that keep experiments shippable
A small TypeScript pattern for deferring provider choices to validated environment configuration without scattering conditionals everywhere.
Fast-moving AI code attracts provider-specific branches. A few weeks later, model names and base URLs are scattered through route handlers, background jobs, and tests.
A small configuration seam keeps experimentation cheap without turning configuration into a framework.
type AiConfig = {
provider: "openai" | "anthropic";
model: string;
timeoutMs: number;
};
export function loadAiConfig(env = process.env): AiConfig {
const provider = env.AI_PROVIDER ?? "openai";
if (provider !== "openai" && provider !== "anthropic") {
throw new Error(`Unsupported AI_PROVIDER: ${provider}`);
}
const model = env.AI_MODEL?.trim();
if (!model) throw new Error("AI_MODEL is required");
const timeoutMs = Number(env.AI_TIMEOUT_MS ?? "30000");
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000) {
throw new Error("AI_TIMEOUT_MS must be at least 1000");
}
return { provider, model, timeoutMs };
}
Why this tiny boundary helps
- Defaults are visible in one place.
- Invalid production state fails loudly during startup.
- Tests can pass a plain object instead of mutating global environment state.
- Application code depends on a stable shape, not on variable names.
- A provider adapter can be selected once at the composition root.
Keep secrets out of this object when callers do not need them. Pass credentials directly into the adapter that owns the network boundary.
Test the contract
import { strict as assert } from "node:assert";
assert.deepEqual(
loadAiConfig({ AI_MODEL: "small-capable-model" }),
{ provider: "openai", model: "small-capable-model", timeoutMs: 30000 },
);
assert.throws(
() => loadAiConfig({ AI_PROVIDER: "mystery", AI_MODEL: "x" }),
/Unsupported AI_PROVIDER/,
);
The point is not the exact variables. It is the seam: external choice becomes validated internal data exactly once.