Concept
The beginner framing: an interface describes the shape a value must have, which properties, and what type each one is, giving both a compile-time contract and, in most editors, real autocomplete for anything typed with it.
interface User {
id: number;
name: string;
email?: string; // optional, may be omitted entirely
readonly createdAt: Date; // cannot be reassigned after creation
}
const user: User = { id: 1, name: "Ada", createdAt: new Date() }; // email omitted, fine
user.createdAt = new Date(); // ❌ error, readonlyConfirmed by compilation: email being marked ? means an object satisfying User may omit it entirely (or set it to undefined) with no error, while readonly on createdAt compiles cleanly on initial assignment but produces Cannot assign to 'createdAt' because it is a read-only property on any later reassignment.
Extension: building interfaces from other interfaces
interface Animal { name: string }
interface Dog extends Animal {
breed: string;
}
const rex: Dog = { name: "Rex", breed: "Labrador" }; // must satisfy BOTHextends composes interfaces, Dog requires everything Animal requires, plus its own additional properties. An interface can extend multiple others (interface Dog extends Animal, Pet { ... }), requiring the union of everything all of them declare.
The excess property check: structural typing's stricter cousin
This is the exception TypeScript Basics flagged as "covered in Interfaces", and it's the single most common source of "wait, why did this suddenly error?" confusion for people who've internalized structural typing's normal permissiveness.
interface Point { x: number; y: number }
function logPoint(p: Point) { console.log(p.x, p.y); }
const location = { x: 1, y: 2, z: 3 }; // assigned to a variable FIRST
logPoint(location); // ✅ compiles fine, normal structural typing, extra property ignored
logPoint({ x: 1, y: 2, z: 3 }); // ❌ ERROR, object LITERAL passed DIRECTLY
// "Object literal may only specify known properties, and 'z' does not exist in type 'Point'"Confirmed by compiling both forms: the exact same shape is accepted when assigned to a variable first, but rejected when passed as a literal directly at the call site. This isn't a contradiction of structural typing, it's a deliberate, additional safety net specifically for object literals, since a literal's excess property is very often a typo (nmae instead of name) that structural typing alone wouldn't catch, since the "extra" misspelled property would just be silently ignored otherwise.
Declaration merging: a genuine interface-only capability
interface Box { width: number }
interface Box { height: number } // SAME name, this MERGES with the declaration above
const b: Box = { width: 10, height: 20 }; // Box now requires BOTH width AND heightConfirmed by compilation: two interface declarations with the same name don't conflict, they merge into a single interface requiring the union of both declarations' properties. This is a genuine interface-specific capability that type aliases cannot do (attempting the same with two type Box = {...} declarations is a compile error), covered in full in Type Aliases.
Try It
Predict the outcome before checking the solution.
interface Settings { theme: string }
function applySettings(settings: Settings) {
console.log(settings.theme);
}
const userPrefs = { theme: "dark", fontSize: 14 };
applySettings(userPrefs); // does this compile?
applySettings({ theme: "dark", fontSize: 14 }); // does THIS compile?Solution
The first call compiles fine, userPrefs is a variable, so normal structural typing applies: it has the required theme: string, and the extra fontSize is simply irrelevant. The second call fails to compile, passing the object literal directly triggers the excess property check, and fontSize isn't a known property of Settings, producing a compile error. Same shape, same values, different outcome purely based on whether the object was a literal at the call site or a variable passed by reference.
Implement It Yourself
Build a function that demonstrates working around the excess property check deliberately, when the extra properties are genuinely intended:
interface ApiResponse { status: number; body: string }
function logResponse(res: ApiResponse) {
console.log(res.status, res.body);
}
// Option 1: assign to a variable first (bypasses the excess property check entirely)
const response = { status: 200, body: "OK", headers: {} };
logResponse(response); // ✅ fine, not a literal at the call site
// Option 2: a type assertion, when you're SURE the extra properties are intentional
logResponse({ status: 200, body: "OK", headers: {} } as ApiResponse); // ✅ also fineBoth approaches sidestep the excess property check, the first because it's no longer a literal by the time it's passed, the second because an assertion explicitly tells the compiler to trust the shape. Neither approach makes the extra headers property accessible through the ApiResponse-typed parameter inside logResponse, the check is only about what's ALLOWED to be passed in, not about hiding or removing the extra data at runtime.
Under the Hood
The excess property check is a targeted exception layered on top of the general structural typing model from TypeScript Basics, understanding both together (normal structural permissiveness PLUS this literal-specific tightening) gives the complete, accurate picture rather than either half alone. Declaration merging's interface-only nature is exactly what Type Aliases covers as one of the concrete, non-folklore differences between the two, not a stylistic preference, but a genuine capability gap.
Common Mistakes
1. Being surprised by an excess property error after structural typing "should" allow it
logResponse({ status: 200, body: "OK", extra: true }); // ❌ "why is this erroring??"This confusion usually comes from only having learned the general structural typing rule, the excess property check is a real, separate exception specifically for literals, not a contradiction of what was learned earlier.
2. Forgetting that readonly only prevents reassignment, not mutation of nested objects
interface Config { readonly settings: { theme: string } }
const c: Config = { settings: { theme: "dark" } };
c.settings = { theme: "light" }; // ❌ error, reassigning `settings` itself
c.settings.theme = "light"; // ✅ NO error, mutating a property OF settings, not settings itselfreadonly is shallow, it only protects the property it's directly applied to, not anything nested inside that property's value.
3. Assuming an accidental duplicate interface declaration is always a merge you wanted
interface User { id: number } // in file A
// ... later, in the SAME scope, someone accidentally writes:
interface User { id: string } // ❌ error, conflicting property type, not silently overriddenDeclaration merging only works cleanly when the duplicate declarations don't directly conflict on a shared property's type, an accidental redeclaration with an incompatible type produces a real error, not silent replacement.
Best Practices
- Use
readonlyfor properties that genuinely shouldn't change after construction (IDs, creation timestamps), it's a real, compiler-enforced guarantee, not just a naming convention. - Don't fight the excess property check by habitually assigning to a variable first just to avoid it, if it's flagging a genuine typo, that's exactly the safety net working as intended; only bypass it when the extra properties are deliberate.
- Reach for interface extension (
extends) when building up related, hierarchical shapes, it reads clearly and composes well with multiple interfaces. - Remember declaration merging is interface-specific, don't rely on it as a general pattern if the code might later need to become a
typealias for other reasons (unions, mapped types), since that capability wouldn't carry over.
Performance Tips
- The excess property check and
readonlyenforcement are both purely compile-time, neither adds any runtime cost; the compiled JavaScript is identical whether or not these checks catch something at build time. - Declaration merging doesn't create any actual runtime overhead either, by the time code runs, the merged interface has done its job entirely at the type-checking stage and has already been erased.
