Skip to content
AtomicReps

Literal types & widening

A lesson from TypeScript: What the Type System Checks. Play it above, or read it through below.

Level I · Type Foundations
  • An unannotated const has a literal type that can still widen

    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 important word is "widening". The release note that introduced the behaviour states both halves in one passage: "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 does not repeat what inference already produced. It removes 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 applies to every member of a union: 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 to each member and not a separate rule for unions.

    This rule answers the review comment "why did you annotate that, it is already a const": the comment is correct about the value and wrong about the type.

  • 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 this rule for every type, not only for objects: obj.counter "must have the type number, not 0, because types are used to determine both reading and writing behavior". When nothing supplies an expected type, string is therefore the only sound type for the writable method. The widening happens when TypeScript infers the object literal as the type of req. The same literal written directly as the argument to a parameter typed "GET" | "POST" keeps "GET".

    For example, a developer reports "the types broke when I pulled the request object out" in an issue about handleRequest, on a diff whose only change was assigning the object to a named variable.

  • A union allows only the operations that every member supports

    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 removes operations. This lesson covers unions for a different reason: 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".

    To use an operation that only one member supports, narrow the value first; a later lesson covers narrowing. The common wrong fix converts the value instead: a pull request wraps id in String(id) so that string methods compile, and at runtime an array such as [1, 2] becomes the string "1,2".

  • as const applies only to 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 turn off, for that expression only, the widening of writable properties and of values read into a let.

    Outside that expression nothing changes, and the docs state the limit directly: "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 also syntactic, and it rejects a common refactor: "const assertions can only be applied immediately on simple literal expressions". Wrapping a ternary is an error, so the assertion has to go on each branch. The hour line in the figure is an error for the same reason: every operand in the multiplication is a literal, but the product is an expression, not a literal. Write 3_600_000 as const instead.

    The first limit produces no compile error. A reviewer approves the object, code in another module pushes a value into the shared array, and later someone asks why the config differs between two requests without connecting the difference to the approved palette.

  • 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 tag property whose type widened to string cannot tell the cases apart. A helper returning [{ kind: "circle", radius: 100 }, { kind: "square", sideLength: 50 }] with no assertion gives every element a kind of type string, because a writable property gets the widened type, and a string tag cannot distinguish the cases, 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 compares against two disjoint literal types, which is the release note's own worked example and its own comment, "Narrows perfectly!". The two versions differ only by as const, and the error can appear in a different file from the helper that caused it.

    The compiler reports the error at shape.radius, in the consumer, which is the only code with no mistake in it, and a reviewer who reads only the diff sees the compiler demand an undefined check on a field the author knows is always present.