Skip to content
AtomicReps
Types That Earn Their Keep

Structural typing & excess property checks

Lesson 4 of 27

Structural typing & excess property checks

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

  • 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 second check laid on top of it, and it fires only on an object literal at the point that literal is assigned or passed.

    That is why the two lines behave differently, and it is also why the compiler can afford to be helpful there and nowhere else: a literal written against a known target is the one place where an unknown key is almost certainly a typo rather than an intentional extra field, so you get a spelling suggestion instead of a member-list comparison. There are three documented ways around the check and they are not equal.

    A type assertion says you have decided; a string index signature says the shape genuinely accepts arbitrary extra keys, and it is the only one of the three that changes the type rather than dodging the question; and assigning through a variable is the accidental one, because nobody reaches for it as a workaround, they reach for it because the config needed a line of preparation and the check quietly stopped applying.

    The symptom is a support ticket that says the dark theme flag does nothing on the settings page, filed against the theme service, whose author reads the call site, sees a key with the right name in it, and closes it 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 count in one direction: a value is assignable to a target when it has

    Two types with different names and the same members are the same type to this compiler, and the name exists for you rather than for it. The report that eventually arrives is not a type error at all, it is two people in a design review discovering that the User in the billing package and the User in the auth package have been silently interchangeable for a year, filed as a documentation problem.

  • One private field turns the whole class nominal.

    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 structural rule doing exactly what it did when the compiler ignored the names you gave those types.

    Then 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, in the one place a nominal rule exists in this language, and it is not a stricter member comparison but a different question entirely, since a byte-for-byte copy of the class in another file has its own declaration and is therefore its own type forever.

    What it buys is inheritance: a subclass carries its parent's private members from the parent's declaration, so it stays assignable to the parent while everything else that looks like the parent stops being. 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 numbers in both directions 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 check is on which declaration the member came from rather than on what it evaluates to.

    That combination is why the failure lands where it does: a status code read from the wire is accepted into OrderStatus without a single check, and the ticket says orders are showing a status that does not exist in the dropdown, filed against the dropdown.

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

  • Only interfaces reopen, and the reopened 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, and that asymmetry is the difference between the two constructs that a working codebase cashes in most often.

    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 ordering that nobody reads comes in: 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 bubbled toward the top of the merged list ahead of all of it.

    Merging is also global by default, and that is the half that bites: 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 is gone, which is the fix nobody guesses from the message, because the message points at a library.

  • 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. Where does obeying it stop being a style question and start producing a diagnostic?

    An interface describes the shape of an object and nothing else, so the moment a name has to stand for something that is not one object shape, only a type alias can hold it. type Result = Success | Failure has no interface spelling: interface Result extends Success, Failure is not the same declaration weakened, it is the opposite one, requiring both instead of either, and a reader who reaches for it because the lint rule said interfaces get a working program with an inverted contract.

    There is a second limit that costs more code-review arguments than it should, and it turns up 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.

    The report that comes from getting the union half wrong is the least dramatic in this lesson and the most expensive to unpick: a discriminated union modelled as an intersection compiles, so every consumer is written against a shape that no value ever has. It surfaces months later as "the error branch never renders", filed against the component that renders it, by someone whose fix is another optional field.