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