← Essays
§ Essay

When string is a lie

A lot of responses from good APIs contain fields whose value is always one of a small set of strings. Stripe puts "object": "customer" on every customer. Discord puts "type": 0 on every text channel. Your own backend probably sends "status": "succeeded" for every successful payment.

These are discriminants. They exist to tell the reader which variant of a union they are looking at. And when a type generator widens them to string, it destroys their reason for existing.

Look at a Stripe invoice and a Stripe charge side by side:

{ "id": "in_123", "object": "invoice", "amount_due": 5000 }
{ "id": "ch_456", "object": "charge",  "amount":     5000 }

If both types say object: string, TypeScript cannot tell which is which in a function that accepts either. You have to reach for in checks or typeof guards or — more commonly — unsafe casts and prayers.

If the types say object: "invoice" and object: "charge", the compiler knows:

type StripeObject = Invoice | Charge;

function totalCents(o: StripeObject): number {
  switch (o.object) {
    case 'invoice': return o.amount_due;  // TS knows: Invoice
    case 'charge':  return o.amount;      // TS knows: Charge
  }
}

That narrowing comes free. But only if the literal survives the type generator.

This is where most tools fail. Paste a single Stripe customer into a generic JSON-to-TS converter and you will get object: string. The tool saw "customer" once and decided to “play it safe” — as if the API might send "invoice" back next time in the same response. It will not. Stripe’s contract is clear. The safe move is to believe the API, not to hedge against it.

string is what you write when you have given up on knowing. If the API knows, the type should know.

— the house style

The inference heuristic

The rule this site uses for literal inference is narrow:

  • The field value is a string.
  • Short — usually under 24 characters.
  • No whitespace.
  • The field name is a plausible discriminant: type, object, kind, status, event, _type, or a suffix variant.

When all four match, the inferred type is a literal. Otherwise it is string. The bias is toward not inferring literals in ambiguous cases, because a false positive is worse than a false negative here: a wrongly-literal type rejects a real value at compile time, while a wrongly-widened type just misses a narrowing opportunity. The former breaks builds; the latter costs you one switch case.

Some fields are easy; some are not

Easy — discriminants are obvious:

{ "event_type": "payment.succeeded", "data": {...} }
{ "event_type": "payment.failed",    "data": {...} }

Literal.

Harder — looks like a discriminant but isn’t:

{ "slug": "my-first-post", "title": "..." }

slug is not a discriminant; it is an identifier. Widen to string.

Tricky — technically a discriminant, semantically ambiguous:

{ "provider": "stripe", "customer_id": "cus_abc" }

provider is probably a small closed set (stripe | paypal | square). But you would want more than one sample to be confident. Feed two or three payloads covering different providers, and the generated type becomes:

type PaymentMethod =
  | { provider: 'stripe'; customer_id: string }
  | { provider: 'paypal'; customer_id: string }
  | { provider: 'square'; customer_id: string };

Which is exactly the type you wanted and could not have written from a single sample. The general rule: feed the tool enough samples to cover the real variation in the field you care about, and let it infer the union. One sample gives you a guess. Three samples give you the shape.

“What if the API adds new variants later?”

Valid concern. The escape hatch is an opt-in widen: mark the field as string explicitly when you want future-proofing. But do not default-widen. Default specificity. Opt into vagueness when you need it.

The most frustrating thing about string-everywhere types is not that they compile. It is that they compile and then never catch anything. You ship, you read a different field name in production logs than what you were handling, and you realize your types never had a chance to help you.

A type that always says string is a type that has never said no. And a type that never says no is a type you have not really gotten your money’s worth out of.