Skip to content
AtomicReps

Structural typing & excess property checks

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

Level I · Type Foundations
  • The literal is checked where you wrote it. The variable is not.

    The common read: the check is a property of the object, so a value that fails a shape check fails it wherever I write it.

    Excess property checking is not part of assignability. It is a separate check, and the compiler runs it only on an object literal at the point that literal is assigned or passed.

    That is why the two lines behave differently. The excess property check reports the exact unknown key it found, so when the target declares a key with a similar spelling, the message adds Did you mean to write 'color'?. On the variable line the excess property check does not run. Only the ordinary assignability check runs, and that check never looks for unknown keys, so it reports no key and suggests no spelling. There are three documented ways around the check and they are not equal.

    A type assertion says you have decided the type yourself. A string index signature says the shape accepts arbitrary extra keys, and it is the only one of the three that changes the type rather than skipping the check. Assigning through a variable is the accidental one, because nobody chooses it as a workaround: developers write it because the config needed a line of preparation, and the check stopped applying with no error.

    For example, a user reports that the dark theme flag does nothing on the settings page, the report goes to the theme service, and its author reads the call site, sees a key that looks like the right name, and closes the report as unreproducible.

  • The compiler never reads the name you gave the type.

    The common read: The annotation Pet means the value came from something declared Pet, so an unrelated object that happens to have the same fields will be rejected.

    A Response object from your HTTP client satisfies a hand-written { status: number } parameter with no adapter, no implements, and no import of the library's type at all. What is the compiler comparing when it accepts that call?

    The rule is a member check in one direction: a value is assignable to a target when it has

    The compiler treats two types with different names and the same members as the same type, and the name exists for the human reader, not for the compiler. The resulting problem is not a type error: for example, two people in a design review discover that the User in the billing package and the User in the auth package have been interchangeable with no error for a year, and they record it as a documentation problem.

  • One private field makes the compiler compare the whole class by declaration.

    The common read: TypeScript compares members, so two classes with the same members are interchangeable and adding a field the outside cannot reach cannot change that.

    Classes are compared on their instance members and nothing else, so statics and constructors are ignored entirely and two unrelated classes with the same fields substitute for each other in both directions, which is the same structural rule that made the compiler ignore the names you gave those types.

    One modifier changes the kind of comparison being performed: when the target type contains a private member, the source must contain a private member that originated from the same declaration, and the same holds for protected. That is a nominal rule, one of the few in this language (enums, two screens on, are another), and it is not a stricter member comparison but a different check, since a byte-for-byte copy of the class in another file has its own declaration and is therefore always a separate type.

    The rule allows inheritance: a subclass has its parent's private members from the parent's declaration, so it stays assignable to the parent, while any other class with the same members stops being assignable. Adding private to an existing class is therefore never a local change.

  • Two enums, one number, no assignment.

    The common read: an enum member is a number with a label on it, so anywhere a number fits, any enum member of the same value fits too.

    Two numeric enums declare a member with the same value. Which of these three does the compiler reject: the bare 1 into an enum-typed variable, the enum member into a number, or one enum's member into the other enum's type?

    Enums are compatible with the number type in both directions (a literal such as 7 that is not a member value is still rejected) and

    Assign 1 to a variable typed as a numeric enum and it passes; read an enum member into a number and it passes; assign one enum's member to another enum's type and the compiler rejects it even when both members are literally the same number, because the compiler compares enums by name first: a member of one enum is never assignable to an enum with a different name, whatever its value.

    That combination decides where the failure appears: a status code read from a network response is accepted into OrderStatus with no check, and a user reports that orders show a status that does not exist in the dropdown, so the bug is assigned to the dropdown.

    Same members? Usually same type. Compiler no read name. Private field: must come from same declaration. Enum member: must come from enum with same name. Both ask where member come from. Not what member look like.

  • An interface can be declared twice and a type alias cannot, and the merged list is not in your order.

    The common read: two declarations of one interface produce one list of members in the order I wrote them, so an augmentation is appended and the original signature is still what matches first.

    Declare an interface name twice in one scope and the two declarations merge into one; declare a type alias name twice and you get a duplicate identifier error.

    Non-function members have to be unique or identically typed, and function members of the same name become overloads of one function, which is where the rarely read ordering rule applies: the groups merge with later overload sets ordered first, each group keeping its internal order, and then any signature whose parameter is a single string literal type is moved to the top of the merged list, ahead of every group.

    Merging is also global by default, and that causes the most confusing errors: an interface at the top level of a file with no import and no export is in the global scope, so declaring interface Blob { bytes: number } there does not create your own Blob, it adds a member to the DOM's, and the compiler then reports Duplicate identifier 'bytes' at a line number inside lib.dom.d.ts that names a member you have never seen.

    Add one export {} to that file and the error disappears, a fix that is hard to guess from the message, because the message points at a library file.

  • An interface cannot name a union, and it does not fit a Record parameter.

    The common read: interface and type are two spellings of one construct, so the choice is a style-guide question and a lint rule settles it.

    A lint rule in your repo says prefer interface over type, and the reviewer who wrote it calls the choice cosmetic. At what point does following that rule produce a diagnostic?

    An interface describes one object shape, so a name that stands for anything else needs a type alias. type Result = Success | Failure has no interface spelling. interface Result extends Success, Failure {} asks for both shapes at once instead of either one. When the two shapes disagree on a shared field, such as ok: true and ok: false, the compiler rejects it with TS2320. When they share no field, it compiles and requires every result to carry both data and error.

    There is a second limit that causes many code-review arguments, and it appears the first time somebody writes a logging helper: an interface-typed value is rejected where a Record<string, unknown> parameter is expected, and a type-typed value with the identical members is accepted, because the alias gets an implicit index signature and the interface does not. So the choice is not a preference in either direction.

    Writing a discriminated union as an intersection fails loudly. When ok: true meets ok: false, Success & Failure reduces to never, so every value passed to it is a compile error. interface Result extends Success, Failure {} also fails, with TS2320, because the two ok types differ. The mistake that compiles is two shapes with no shared field. There, extends succeeds and the contract says "both" where the design meant "either". Every caller then has to supply a data and an error to satisfy it.