Skip to content
AtomicReps

Literal types & widening

A lesson from Types That Earn Their Keep. Play it above, or read it through below.

  • const defers the decision, it does not lock it

    The common read: A const holding a literal has that literal as its type, so the annotation on the second one is documentation and changes nothing.

    An unannotated const gets a widening literal type, and the word doing the work is "widening". The release note that introduced the behaviour names both halves in the same breath: "when an expression of a literal type is inferred for a const location without a type annotation, that const variable gets a widening literal type inferred", and "when a const location has an explicit literal type annotation, the const variable gets a non-widening literal type".

    So the annotation is not a restatement of what inference already produced, it is the removal of a permission that inference attached, and the two declarations differ in exactly one thing that no editor tooltip on the declaration line will show you.

    Widening then propagates through a union without any of the members surviving individually: nightly ? 1 : "unlimited" is inferred as 1 | "unlimited" on a const and as string | number the moment it is read into a let, which is the same rule applied member by member and is not, as it looks, a separate ability to collapse unions.

    This is the beat behind the review comment that reads "why did you annotate that, it is already a const", which is correct about the value, wrong about the type, and cheap to say.

  • A property you can write to gets the type of everything you could write

    The common read: The object is declared const and I wrote "GET" right there in it, so the property holds "GET".

    req is a const object literal and method is written as "GET" right there in it. What type does req.method get?

    const says nothing about the properties of the thing it holds. It stops one binding from being pointed at a different object, and req.method = "POST" on the next line compiles cleanly, which is

    The handbook states the rule against its own counter-example and states it as a general fact about types rather than as an exception for objects: obj.counter "must have the type number, not 0, because types are used to determine both reading and writing behavior". So the widening is not a heuristic about how confident the compiler feels, it is the only sound type for a writable slot, and it happens at the object literal, before the value has gone anywhere.

    What arrives in review is never any of that. It arrives as "the types broke when I pulled the request object out", filed against handleRequest, on a diff whose only change was giving a value a name.

  • A union is a set of values, and it costs you members

    The common read: A union type combines two types, so a value typed number[] | string gives me everything an array can do plus everything a string can do.

    number[] | string is the set of values that are either an array of numbers or a string. Which operations does that leave you?

    The operations that remain safe on such a value are

    A union subtracts. The reason this lesson owns unions at all is the other direction: a union of literal types is how you model a closed set of values with no runtime object behind it, "idle" | "loading" | "done" rather than an enum, and boolean itself is built the same way, since "The type boolean itself is actually just an alias for the union true | false".

    What you get back is narrowing, which is the subject of a later lesson and is named here only so the shape is complete. The bug this beat prevents does not look like a type error at all. It arrives as a pull request that adds String(id) around a parameter, filed as "the types were fighting me", because the author read number | string as a promise of string methods and made the compiler right by force.

  • as const stops at the expression it is written on

    The common read: as const locks the structure, so anything reachable through this object is readonly and a push into it will not compile.

    The assertion is scoped to one expression and it is scoped syntactically. Inside that expression it does three things, and the release note lists them as three: "no literal types in that expression should be widened (e.g. no going from "hello" to string)", "object literals get readonly properties", and "array literals become readonly tuples", which together are the switch that turns off everything S3 and S2 established, for that expression only.

    Outside it, nothing changes, and the docs state the limit flatly: "const contexts don't immediately convert an expression to be fully immutable", illustrated with a pre-declared array whose push still compiles through an asserted object.

    The second limit is syntactic in the other direction and it catches the obvious refactor: "const assertions can only be applied immediately on simple literal expressions", so wrapping a ternary is an error and the assertion has to go on each branch, and (60 60 1000) as const is an error for the same reason even though every token in it is a literal.

    Neither limit produces a bug report. It produces a review approval, and then a value someone pushed into from another module, and then a question about why the config differs between two requests that nobody connects to the palette anyone approved.

  • The narrowing that fails in another file

    The common read: If a value's shape is right, code that checks its tag will narrow it, so a helper returning the right objects is a helper that works.

    Every object the helper returns carries the right kind, and the consumer checks that tag before it reads radius. What is shape.radius inside that branch?

    A discriminant that widened is not a discriminant. A helper returning [{ kind: "circle", radius: 100 }, { kind: "square", sideLength: 50 }] with no assertion gives every element a kind of type string, by exactly the rule S3 established, and a string tag distinguishes nothing, so if (shape.kind === "circle") narrows nothing and shape.radius is number | undefined inside the branch that was supposed to guarantee it.

    Add as const to the returned array and the same code compiles with no annotations anywhere, because the elements now carry readonly kind: "circle" and the check has two disjoint literal types to choose between, which is the release note's own worked example and its own comment, "Narrows perfectly!". The distance between the two versions is five characters and the distance between the cause and the symptom is however many files sit between the helper and the consumer.

    That is what makes this the shape worth remembering out of the whole lesson: the error is reported at shape.radius, in the consumer, which is the one piece of code doing everything right, and the reviewer who reads only the diff sees a null check being demanded on a field the author can see is always present. The one file with no mistake in it is the one holding the error.