Skip to content
AtomicReps
TypeScript: What the Type System Checks

A clean build does not mean every line was checked

Lesson 1 of 27

A clean build does not mean every line was checked

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

Level I · Type Foundations
  • Inference uses only the values present, and where there are none it uses any

    The common read: TypeScript works out a type from how a value is used, so leaving the annotation off costs nothing and the checker fills in whatever I meant.

    Inference does not guess what you intended. A variable's type comes from its initializer, an array's element type comes from a best common type chosen from the candidates present, and where those candidates share a base class that no element of the array is, the handbook's own worked example says the compiler makes no inference about the element type and returns the union instead.

    An initialized variable keeps the type its initializer gives it, and a later assignment of another type is an error. Two declarations work differently when noImplicitAny is on, which strict turns on: let x; with no initializer, and const a = []. Their type at each line is set by the most recent assignment or push above it. An unannotated parameter has no value for the compiler to read, so its type is any.

    A flag, not a language rule, makes that fallback produce a diagnostic: noImplicitAny, which is on by default in 7.0.2 because strict defaults on. It reports an error where the compiler itself fell back to any (TS7006 on an unannotated parameter, TS7008 on a member of an object type with no type of its own), and it reports nothing about an any that came from a library signature or that you wrote yourself.

    Because of that gap, a reviewer can write "this file is fully typed, strict is on, we are covered" on a configure(opts: { retries: number; onError: any }), which accepts any value in its second field and builds with no error, and three sprints later someone passes an object and reports a callback bug in the consumer. Annotate the boundaries and let inference type everything else. The annotation is the only way to state a type that the values themselves do not already imply.

  • The types are a second program, and that program never runs

    The common read: The types are part of the program the machine runs, so a shape written in type position is still being enforced while the code executes.

    How much of what you write in type position remains after the build?

    Point tsc at a file that annotates everything - a type alias, typed parameters, a typed return. The emitted JavaScript contains

    tsc deletes the annotations and emits the JavaScript underneath, and the handbook states the consequence in one sentence: "Type annotations never change the runtime behavior of your program". A .ts file contains two programs in one set of characters: one of them runs, and the other is checked once, at build time, by a checker that has no access to any value your users will send.

    The second program is also optional: the type of a variable is inferred from its initializer and most positions never need an annotation.

    For example, a developer reports "the Cents type is not taking effect in production, the build must be serving an old bundle" against the pipeline, after searching the deployed file for the word Cents and finding nothing, which in their previous language would have meant an old build. If you want a check at the boundary, you write one as runtime code; the annotation does one thing, which is to make the compiler check the rest of the file against it.

  • any turns off type checking for every value it touches

    The common read: strict is on, so anything the compiler did not flag has been checked, and an as is how I tell it something it could not work out for itself.

    A clean build is not evidence that anything was checked. any is not a permissive type that accepts many values; it is an instruction to stop checking. The effect spreads, because every property you read from an any is itself any and is passed into the next function with checking still off.

    An assertion is the same gap in checking with a more specific type. With as, you are the only person verifying the claim. An assertion is erased exactly like an annotation, "there is no runtime checking associated with a type assertion", and "there won't be an exception or null generated if the type assertion is wrong", so the only thing as User changes is what the compiler will say about the lines after it.

    There is one check and it catches less than it appears to: assertions are only permitted between types that are more or less specific versions of each other, which rejects "hello" as number and rejects nothing at all when the source is any. When that check rejects an assertion, the documented way past it is two assertions, expr as any as T.

    For example, a reviewer approves const data = response as UserProfile as a validation step, and the resulting bug is reported against whoever wrote the consumer, three modules away, with a stack trace that never mentions the line with the false assertion. Put an assertion only where you would be willing to write a comment saying "trust me", because the compiler checks only that the two types overlap, and after that it trusts the claim on every line that uses the value.

  • unknown accepts every value but allows no operations, and never holds no values

    The common read: unknown is any with an extra warning and never is another way of writing void, so using any costs me a lint complaint at worst.

    Swap an any at a boundary for an unknown. Which values does that reject?

    The swap rejects no values. unknown holds exactly the same values any holds and grants exactly none of the same permissions. The handbook puts the two side by side: unknown "represents any value. This is similar to the any type, but is safer because it's not legal to do anything with an unknown value", which means no property read, no call, no arithmetic, until you have checked what the value is, and 7.0.2 reports all three as TS18046 on the line that attempts the operation.

    never is the type no value inhabits, and the reference states the pair as inverses directly: "Everything is assignable to unknown, never is assignable to everything. Nothing is assignable to never, unknown is not assignable to anything (except any)." any is not the top of this lattice; it is an exception to it: assignable both ways to everything, and rejected by exactly one type, never.

    The pattern JSON.parse(raw) as Payload often appears in postmortems: the team blames the upstream service for sending a bad payload and closes the incident with a schema change the upstream service did not need. const raw: unknown = JSON.parse(...) prevents that incident: the values and the lack of information are the same, but unknown makes you write the check before you can write the consumer. Use unknown at every boundary you do not control, and use never to mark a branch as impossible.

  • A name is resolved in either the value space or the type space

    The common read: A type name and a value name are the same name, so String and string are one thing spelled two ways and typeof x in a type is the operator I already know.

    The same characters mean different things depending on whether you wrote them in a value position or a type position. The handbook has no single name for this rule and states it as a reminder instead, "Remember that values and types aren't the same thing". const Config = {...} and type Config = typeof Config can be declared in one file with no collision, which is legal at 7.0.2 and is the common idiom in TypeScript configuration code.

    String, Number and Boolean are legal types referring to different built-ins. The handbook's instruction is "Always use string, number, or boolean for types", and the wrapper declares every method the primitive has, so the mistake compiles at every use until the first place that requires the primitive type.

    The same problem appears again with three similar names: object, which "refers to any value that isn't a primitive", the global Object, which admits primitives too, and the empty object type {}, which admits everything except null and undefined. The handbook's instruction for choosing between them is "object is not Object. Always use object!".

    typeof gets the type of a value, and in type position it is not the JavaScript operator you already know: it is legal only on identifiers or their properties, so typeof makeUser() is not a narrower feature but a syntax error, and what you wanted has a name in another lesson, ReturnType<typeof makeUser>. Read the position before you read the name. When a review comment says "why is this capitalized", treat it as a bug report rather than as a style note.