# Making token budgets a type error

> Source: https://learn-typescript.org/ai/tokenomics/
> Part of Learn TypeScript, free to read.

The economics are the same everywhere — [what tokens cost and where the money goes](https://codelearningdojo.com/token-economics/) is the model, and the [JavaScript page](https://learn-javascript.org/ai/tokenomics/) 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.

```ts src/cost/units.ts
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)}`;
```

```ts
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.

```ts src/cost/prices.ts
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.

```ts src/llm/client.ts
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.

```ts
// 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.

```ts src/cost/features.ts
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:

```ts
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.

```ts src/llm/prompt.ts
/** 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.

```ts
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.

```ts
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.

:::verdict 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.
