Making token budgets a type error
You cannot type-check a bill. You can make it impossible to call a model without a budget, impossible to confuse dollars with tokens, and impossible to add a call site nobody is measuring.
The economics are the same everywhere — what tokens cost and where the money goes is the model, and the JavaScript page covers the runtime mechanics that apply here unchanged.
What TypeScript adds is narrower and genuinely useful: the cost mistakes that come from an unmeasured call site or a confused unit can be made unrepresentable.
Brand the units#
Three numbers float around any cost system — tokens, micro-dollars, dollars — and they are all number. Mixing them is a silent, expensive bug: dividing by a million twice, or adding an input count to an output count.
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
export type InputTokens = Brand<number, "InputTokens">;
export type OutputTokens = Brand<number, "OutputTokens">;
export type CachedTokens = Brand<number, "CachedTokens">;
/** Integer micro-dollars. Never float dollars in an accumulator. */
export type MicroUSD = Brand<number, "MicroUSD">;
export const inputTokens = (n: number) => n as InputTokens;
export const outputTokens = (n: number) => n as OutputTokens;
export const microUsd = (n: number) => Math.round(n) as MicroUSD;
export const addUsd = (a: MicroUSD, b: MicroUSD) => microUsd(a + b);
export const formatUsd = (m: MicroUSD) => `$${(m / 1e6).toFixed(6)}`;const total = addUsd(a, b); // fine
const wrong = addUsd(a, tokens); // compile error. good.MicroUSD as a branded integer solves two problems at once: the unit confusion, and the float-money problem that quietly loses fractions of a cent across a million rows.
Make the price table exhaustive#
A new model tier added to the union is a compile error at every place that prices one. That is exactly what you want — the alternative is a silent fallback to the wrong rate.
export const MODEL_TIERS = ["small", "mid", "large"] as const;
export type ModelTier = (typeof MODEL_TIERS)[number];
type Rate = { in: number; cachedIn: number; out: number }; // per 1M tokens
// Record<ModelTier, …> — adding a tier breaks the build until you price it.
export const PRICES: Record<ModelTier, Rate> = {
small: { in: 0.25, cachedIn: 0.03, out: 1.25 },
mid: { in: 1.00, cachedIn: 0.10, out: 5.00 },
large: { in: 3.00, cachedIn: 0.30, out: 15.0 },
};
export function cost(tier: ModelTier, u: Usage): MicroUSD {
const p = PRICES[tier];
const fresh = Math.max(u.input - u.cached, 0);
return microUsd(fresh * p.in + u.cached * p.cachedIn + u.output * p.out);
}Prices belong in config, loaded and validated at startup, rather than as literals — they change. The type is what stays in the code.
Require a budget to make a call#
The structural move. If the client's signature demands a budget, there is no way to add an unmetered call site.
export interface Budget {
readonly capUsd: MicroUSD;
readonly maxTurns: number;
spent(): MicroUSD;
turns(): number;
/** Throws BudgetExceeded. Called before the request leaves. */
reserve(estimate: MicroUSD): void;
charge(actual: MicroUSD): void;
}
export interface CompleteArgs {
feature: Feature; // a union, not a string — see below
tier: ModelTier;
budget: Budget; // not optional. this is the point.
messages: Message[];
maxOutputTokens: OutputTokens; // also not optional
}
export interface LLM {
complete(args: CompleteArgs): Promise<{ text: string; usage: Usage; cost: MicroUSD }>;
}Two fields there are deliberately non-optional:
budget— you cannot forget it, because the code will not compile.maxOutputTokens— output is the expensive side, and an unset limit is how a classification endpoint ends up generating four thousand tokens of preamble.
// compile error: Property 'budget' is missing
await llm.complete({ feature: "classify", tier: "small", messages, maxOutputTokens: outputTokens(64) });Make feature a union, not a string#
Attribution is worthless if the tags drift. "classify", "classification" and "Classify" produce three rows in your report and one wrong conclusion.
export const FEATURES = [
"ticket_classification",
"reply_draft",
"doc_summary",
"agent_loop",
] as const;
export type Feature = (typeof FEATURES)[number];Now the compiler enforces your taxonomy, your report groups correctly, and adding a feature is a deliberate act rather than a typo.
The same applies to a per-feature budget table — Record<Feature, MicroUSD> means a new feature cannot ship without someone deciding what it is allowed to spend:
export const FEATURE_CAPS: Record<Feature, MicroUSD> = {
ticket_classification: microUsd(2_000),
reply_draft: microUsd(20_000),
doc_summary: microUsd(50_000),
agent_loop: microUsd(500_000),
};Type the prompt so the cache can hit#
Cache hits depend on a stable prefix. You can encode that shape in a type instead of relying on everyone remembering the ordering rule.
/** The parts are ordered by volatility. Construction enforces it. */
export interface PromptParts {
/** Never changes between requests. Cached. */
readonly system: string;
/** Changes rarely — tool schemas, few-shot examples. Cached. */
readonly stable: readonly string[];
/** Changes every request. Never cached, always last. */
readonly volatile: string;
}
export function buildMessages(p: PromptParts): Message[] {
return [
{ role: "system", content: p.system, cache_control: { type: "ephemeral" } },
...[...p.stable].sort().map((content) => ({ // sort: stable order
role: "user" as const, content, cache_control: { type: "ephemeral" as const },
})),
{ role: "user", content: p.volatile }, // the only varying part
];
}buildMessages is the only way to construct a request, so the ordering rule cannot be violated by a new call site. The sort() guards against documents arriving from a Set or an unordered query — different order, different prefix, total cache miss, and no error anywhere.
test("only the volatile part differs", () => {
const a = buildMessages({ system: SYS, stable: ["b", "a"], volatile: "q1" });
const b = buildMessages({ system: SYS, stable: ["a", "b"], volatile: "q2" });
expect(a.slice(0, -1)).toEqual(b.slice(0, -1));
});Discriminated results instead of thrown budget errors#
A budget failure is an expected outcome, not an exception. Making it part of the return type forces the caller to decide what to do.
export type CompletionResult =
| { ok: true; text: string; usage: Usage; cost: MicroUSD }
| { ok: false; reason: "budget_exceeded"; spent: MicroUSD; cap: MicroUSD }
| { ok: false; reason: "turn_limit"; turns: number }
| { ok: false; reason: "provider_error"; status: number; retryable: boolean };
const r = await llm.complete(args);
switch (r.reason) {
case undefined: return r.text; // ok: true
case "budget_exceeded": return degradeToTemplate(); // a real product decision
case "turn_limit": return escalateToHuman();
case "provider_error": return r.retryable ? retry() : fail();
default: return assertNever(r);
}assertNever means adding a failure reason is a compile error everywhere it is handled. That is the difference between a cost control you designed and one that surfaces as a 500.
What the types buy you
Not a smaller bill on their own — a model can be perfectly typed and still expensive. What they buy is that the cost mistakes which come from drift cannot happen: an untagged call site, a mistyped feature name, a tier with no price, a call with no budget, a prompt assembled in the wrong order. Those are the ones that survive review, because each looks correct on its own.
Common questions#
Is branding tokens and money overkill?#
For a prototype, yes. For anything that bills a customer or has a budget someone signed off, no — unit confusion in money arithmetic is a well-known and expensive class of bug, and branding costs about fifteen lines once.
Why micro-dollars instead of Decimal?#
Integer micro-dollars are exact, fast, and need no dependency, which suits per-request accounting where you are summing millions of tiny values. Reach for a decimal library at the invoicing boundary if you need arbitrary precision or defined rounding for a customer-facing figure.
Does requiring a budget parameter get annoying?#
It is a little more typing at each call site, and that friction is the feature — it is what stops an unmetered call from being added by accident. Provide a Budget.unlimited() for scripts and tests so the escape hatch is explicit and greppable rather than implicit.
Do these types stop me overspending?#
No. They stop the silent failures — the untagged call, the wrong unit, the missing price, the prompt built in cache-hostile order. Actually spending less is prompt caching, model routing and output limits, and those are the same in every language.
Get the TypeScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.