Ch. 2 · TypeScript

TypeScript Structural Typing and Excess Properties

TypeScript compares shapes, not names, so extra properties are usually fine; learn when the excess property check fires and when it doesn't.

~5 min readintermediateupdated Oct 7, 2026

Coming from Java or C#, the first surprise in TypeScript is that a class doesn’t need to declare implements Point to be used as a Point. If it has the right properties, it fits. That’s structural typing, and it matches how JavaScript code is actually written: objects are bags of properties, and functions care about what’s there, not what the object was called when it was made. The second surprise follows from the first. Sometimes TypeScript rejects an object with extra properties, and sometimes it doesn’t. Interviewers ask about both because the rules look inconsistent until you know why they exist.

Shapes, not names

interface Point { x: number; y: number }

class Vector {
  constructor(public x: number, public y: number) {}
  length() { return Math.hypot(this.x, this.y); }
}

function plot(p: Point): string { return `${p.x},${p.y}`; }

plot(new Vector(3, 4)); // ok
TypeScript

Vector never mentions Point. It’s accepted because it has x: number and y: number, and the extra length method doesn’t matter. The rule is “at least these properties, with compatible types”. A Point parameter is a minimum requirement, not an exact description.

This is what makes TypeScript easy to adopt over existing JavaScript. You can describe the shape a function needs without forcing every caller to inherit from a shared base class. Test doubles are trivial: any object with the right methods is a valid mock. And it’s why interface and type aliases are mostly interchangeable for object shapes, since neither creates a nominal identity.

The flip side is that two types with the same structure are the same type as far as the checker is concerned:

class Celsius { constructor(public value: number) {} }
class Fahrenheit { constructor(public value: number) {} }
function boil(t: Celsius) { return t.value >= 100; }

boil(new Fahrenheit(212)); // compiles
TypeScript

No error. Both classes are { value: number }. If you need the compiler to tell them apart, you need a brand or a private field, covered at the end.

The excess property check

Now pass extra properties in two different ways:

const reading = { x: 1, y: 2, label: "sensor A" };
plot(reading);                           // ok
plot({ x: 1, y: 2, label: "sensor A" }); // error
TypeScript
st1.ts(9,20): error TS2353: Object literal may only specify known properties, and 'label' does not exist in type 'Point'.
Text

Same value, different results. That’s not structural typing changing its mind. It’s a separate, deliberately narrow rule layered on top: when an object literal is written directly where a specific type is expected, any property that type doesn’t know about is reported. The literal is “fresh”, meaning no other code can be holding a reference to it, so an unknown property can’t be serving some other purpose. It’s almost certainly a typo or a misunderstanding of the API.

reading, on the other hand, is a variable. It might be used elsewhere where label matters, so passing it to plot is the ordinary “at least these properties” case, and the extra property is allowed.

Where the typo slips through

The check exists mostly for option bags, where a misspelled optional property would otherwise be silently ignored:

type RetryOptions = { retries?: number; backoffMs?: number };
declare function fetchWithRetry(url: string, opts: RetryOptions): void;

fetchWithRetry("/api", { retries: 3, backofMs: 200 });
TypeScript
st1.ts(13,38): error TS2561: Object literal may only specify known properties, but 'backofMs' does not exist in type 'RetryOptions'. Did you mean to write 'backoffMs'?
Text

Helpful. But move the object into a variable first, which happens naturally when options are built conditionally or shared between calls:

const opts = { retries: 3, backofMs: 200 };
fetchWithRetry("/api", opts); // compiles
TypeScript

No error, and the request runs with the default backoff because backoffMs is undefined. opts isn’t fresh, so only structural assignability applies, and { retries: number; backofMs: number } has everything RetryOptions requires (which is nothing, since every property is optional).

The fix is to make the variable’s type explicit at the point of creation: const opts: RetryOptions = { ... } or const opts = { ... } satisfies RetryOptions. Either way, the literal is checked against RetryOptions when it’s written, so the excess property check runs where the typo is.

Weak types catch some of it

There’s a related rule for types whose properties are all optional, which TypeScript calls weak types. Since TypeScript 2.4, assigning a value that shares no properties with a weak type is an error, even when it isn’t a fresh literal:

const weak2 = { timeout: 5 };
fetchWithRetry("/api", weak2);
TypeScript
st1.ts(19,24): error TS2559: Type '{ timeout: number; }' has no properties in common with type 'RetryOptions'.
Text

Without this rule, any object at all would be a valid RetryOptions, which would make the type useless. The check is weak by design, though. As the previous section showed, one correct property (retries) is enough to switch it off, and then a misspelled one goes through.

Getting nominal behaviour when you need it

Some types should not be interchangeable with look-alikes. TypeScript has two tools for that.

Classes with private members are compared by declaration, not structure. A #private field (or a private property) can only come from that class’s declaration, so a look-alike can’t match:

class Account { #balance = 0; deposit(n: number) { this.#balance += n; } }
class FakeAccount { deposit(n: number) {} }
const acc: Account = new FakeAccount();
TypeScript
st1.ts(28,7): error TS2741: Property '#balance' is missing in type 'FakeAccount' but required in type 'Account'.
Text

For primitives like IDs or units, branding is the usual approach: intersect the base type with a unique marker property so only a dedicated constructor function can produce values. That’s covered in the branded IDs note. It would fix the Celsius and Fahrenheit example above.

Both are opt-in, which is the right default. Structural typing is what lets a plain object literal, a class instance and a mock all satisfy the same interface. You add nominal behaviour only for the handful of types where confusing one for another is a real bug.

Interview practice

A teammate reports a “TypeScript bug”: createUser({ name, emial }) errors, but const input = { name, emial }; createUser(input) compiles. createUser takes { name: string; email?: string }. Explain what’s happening and how you’d make the second form fail too.

Answer

It isn’t a bug; it’s two different checks. Structural assignability asks whether the value has at least the required properties with compatible types. { name: string; emial: string } has name, and email is optional, so it’s assignable. The first call fails because of the excess property check, which applies only to fresh object literals written directly where { name: string; email?: string } is expected. A fresh literal can’t be used anywhere else, so an unknown property like emial is almost certainly a mistake. Once the literal is stored in a variable, it might be used elsewhere with that property, so TypeScript doesn’t apply the check. The weak type rule doesn’t help either, because name is required, so the type isn’t weak.

To make the second form fail, type the variable at creation: const input: CreateUserInput = { name, emial } or const input = { name, emial } satisfies CreateUserInput. The excess property check then runs on the literal and reports emial with a “Did you mean to write ‘email’?” suggestion. If the object is built from untrusted input, a runtime schema that rejects unknown keys gives the same protection when the data arrives.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Excess Property Checks

Why object literals reject extra properties while variables are accepted, and how to control the freshness check deliberately.

~3 min readread →
esc