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

Samples
Output
// 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.