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. When the type for that field is
"invoice" | "charge" instead of string, a single
switch narrows each case for free:
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
}
}
Replace those two literals with string and the function stops
typechecking. There is no narrowing path; the compiler does not know which
branch goes with which payload shape. You reach for in guards
or typeof checks or — more commonly — unsafe casts
and prayers.
So the natural reading is: a JSON-to-TS tool ought to look at a few
payloads, see object taking the values "invoice"
and "charge", and emit the literal union. Show the tool the
variation; the tool produces the right type.
The widget below is the tool. Try it.
// type the conversion will appear here… Two samples, two distinct values for the object field, and the result still says object: string. Add a third sample with another object value — same outcome. The tool widens to string no matter how much variation you show it. The next section is about why that is the right thing for it to do.
What the tool actually does
The output is not a bug. With one sample, the tool widens to
string; with two, three, or ten samples whose values differ in
every payload, the tool still widens to string. The
discriminant is right there in the input, plainly varying, and the tool
refuses to encode it as a literal union.
Every JSON-to-TS converter on the public web behaves this way once you
actually look. quicktype, json-to-ts, transform.tools, this site —
they all defer to string for fields whose values are
structured as free-form text, regardless of how many samples you supply.
The behavior is consistent because the underlying constraint is the same.
Why widening is the right default
From the tool's point of view, every string-typed field that varies across
samples looks identical. object takes the values
"invoice" and "charge". slug takes
"my-first-post" and "why-types-matter".
request_id takes "req_abc" and
"req_def". To the tool, these are three instances of the same
pattern: a string field whose values changed between payloads.
The reader knows which is which. object is a real
discriminant; slug is an identifier whose values are
unbounded; request_id is a per-request token. The tool has no
way to tell them apart from values alone — it would need the API
contract, the field's role in the system, or the developer's intent. None
of that is in the JSON.
Now consider what happens if the tool guesses. Suppose it sees two slugs and emits:
slug: 'my-first-post' | 'why-types-matter';
Every new article you publish is a value not in that union. The type
rejects real production data at compile time. The build breaks. The
generated type is unusable until somebody widens it back to
string by hand.
Now the other direction. Suppose the tool widens object:
object: string;
Every real payload still typechecks. The cost is that you write one
explicit narrowing — an if, a guard, a hand-edited type
— instead of getting it for free. The build never breaks because of
the type; you just leave a small amount of compiler value on the table.
A wrongly-widened type costs you a switch case. A wrongly-literal type costs you the build. The defaults are not symmetric, so the safe choice is not symmetric either.
This is why the tool widens. Not because inference is hard, not because
nobody has implemented enum detection — the tool literally cannot
risk the wrong call, because the consequences of the wrong call are
asymmetric and one of them is a broken pipeline. string is
the type that always typechecks against any string input. It is the only
answer the tool can give without business knowledge it does not have.
So where is the lie?
The tool is honest. It says string because string
is what its evidence will support. The lie happens later, when the
generated file lands in your repo and you ship it without thinking.
You know object is one of 'invoice',
'charge', 'subscription', and a handful of
others. You know it because you read the API docs, or because you wrote
the API. The tool does not know it. The moment you accept
object: string as your final type instead of as a starting
point, you have replaced your knowledge with the tool's lack of knowledge,
and called the result a type.
That is the lie. Not the field type itself — the field type was the safest possible answer given what the tool could see. The lie is the decision to treat the tool's output as the contract instead of as a template you refine.
Fixing it at the boundary
The fix is structural. Treat the generated file as raw evidence and add a second file that narrows what you know:
// types/generated.ts — the tool's output, untouched
export interface RawStripeObject {
id: string;
object: string;
amount_due?: number;
amount?: number;
}
// types/stripe.ts — what you actually know
type Invoice = RawStripeObject & { object: 'invoice'; amount_due: number };
type Charge = RawStripeObject & { object: 'charge'; amount: number };
export type StripeObject = Invoice | Charge;
Now switch (o.object) narrows correctly, the optional fields
flip to required on the right branch, and the next time you regenerate
types/generated.ts from a new payload you do not lose your
narrowing — it lives in the second file, untouched.
The two-file split is the part most teams skip. They run the generator,
paste the output as the canonical type, and live with string.
Then six months later somebody writes if (o.object === 'invoic')
with a typo and the compiler shrugs because string matches
anything.
“What if the API adds new variants later?”
That is the narrowed union doing its job. When Stripe ships
"object": "subscription_schedule" and your hand-written
StripeObject does not list it, TypeScript refuses to assign
the new value at every call site that pattern-matches on
object. The complaint is the feature. Add the new variant to
your union and the same warnings tell you exactly where the new branch
belongs.
A type that says string never refuses anything. It compiles
when the API ships a new variant; it compiles when somebody types
'invoic' instead of 'invoice'; it compiles when
a downstream service sends garbage. You ship, you read a different field
value in production logs than what you were handling, and you realize
your types never had a chance to help you.
The tool gave you the most defensible answer it could prove. It is your
job to give it back the answer you can prove. 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.