Production

Two IDs for one customer

TypeFirst · AgileLabs Engineering · 6 min read

The opaque types post makes the case with Email: a validated string should carry its proof in its type. The objection that follows is always the same — fine for email, but do I really need a wrapper around every ID? Here is the answer that changed our minds: the ID types worth having are the ones nobody would invent at a whiteboard. Billing integrations demand them within a month.

An ID with a grammar

Most payment and billing providers don't hand you a bare UUID. Their object IDs are prefixed by kind — cus_ for a customer, sub_ for a subscription, in_ for an invoice — and that prefix is load-bearing information you are throwing away every time you type the parameter string. The smart constructor is the only door in:

// Core/App/Billing/ProviderObjectID.ts
const key: unique symbol = Symbol()
export type ProviderObjectID = Opaque<string, typeof key>
export type ObjectPrefix = "cus" | "sub" | "in" | "pi"

export function createProviderObjectIDE(
  s: string,
): Result<"INVALID_OBJECT_ID", ProviderObjectID> {
  const [prefix, body] = s.split("_")
  if (objectPrefixDecoder.value(prefix) == null)
    return err("INVALID_OBJECT_ID")
  // …body charset + length check, then wrap
  return ok(jsonValueCreate(key)(s))
}

Validation failure is a Result, never a throw. And because the grammar is guaranteed, functions downstream of the proof get to be total:

export function getObjectKind(id: ProviderObjectID): ObjectKind {
  switch (getObjectPrefix(id)) {
    case "cus": return "CUSTOMER"
    case "sub": return "SUBSCRIPTION"
    case "in":  return "INVOICE"
    case "pi":  return "PAYMENT"
  }
}

No default branch, no error path, no Maybe. Given a ProviderObjectID, the kind simply is — so the webhook router can dispatch on it without a single defensive check. Every validate-early function earns some deduce-freely functions later; that's the trade.

The customer who gets a new ID

Now the part nobody designs up front. Customers churn and come back. Billing gets migrated between provider accounts when the company opens a second region. Duplicates get merged after a bad import. Every one of those events mints a new provider customer ID for the same human being — while your invoices, support tickets and audit log all point at the old one.

One ID can't serve both masters. So there are two:

/**
 * ProviderCustomerID changes when billing is migrated
 * or two duplicate customers are merged.
 * CustomerID is ours and never changes:
 * it is what invoices, tickets and the audit log reference,
 * and it survives any number of provider-side identities.
 */
export type CustomerID = Opaque<string, typeof key2>

CustomerID is the person — what the rest of your system references. ProviderCustomerID is the billing record — what the vendor's API answers to. They're both strings underneath, and they are not interchangeable: passing a provider ID where your own identity is required is a compile error, because each opaque type is branded with its own unique symbol. Without the brands, the mix-up compiles fine and surfaces months later, on the day someone first merges two accounts — as an invoice history that silently empties out.

The vendor's IDs aren't unique

It gets better. A provider ID is unique within one of the vendor's accounts, and nothing more. Run a sandbox alongside production, add a second connected account for a subsidiary, or import a competitor's export, and cus_00042 means two different companies depending on which credential you asked with. So the codebase mints its own globally-unique key, built from the stable parts:

// Core/App/Billing/ProviderCustomerKey.ts
// ProviderCustomerKey = {ProviderAccountID}:{ProviderCustomerID}
export type ProviderCustomerKey = Opaque<string, typeof customerKey>

Note what it is not built on: your own CustomerID. The key exists to answer "which remote record is this?", so it is composed entirely of remote facts — and the unique index on that column is what makes the webhook handler's upsert safe. Two facts about the world ("the vendor's IDs collide across accounts" and "a customer's provider identity can change") intersect in one line of type design. If the key had been built on CustomerID, the first sandbox webhook replayed into production would have overwritten a live customer's billing record.

The takeaway

When people hear "make an opaque type per ID" they picture ceremony — UserID, OrderID, fine, whatever keeps you happy. The production value is different: each opaque type is a place to hang a fact about the world. ProviderObjectID carries a grammar. CustomerID carries "identity that survives a billing migration". ProviderCustomerKey carries "the vendor's namespace collides". The comments explaining why sit on the type itself — exactly where the next engineer, wondering why there are two customer IDs, will look. A bare string has nowhere to put any of that.

Build one yourself. The opaque types lesson walks through the unique symbol machinery, and ts-bedrock ships the Opaque helper these types are built on.

← All posts