TypeScript Interfaces: Object Shapes, Excess Property Checks, Declaration Merging, and interface vs type

Key takeaways

Interfaces in TypeScript: optional and readonly props, call signatures, index signatures, extends, declaration merging, implements, and when to prefer interface vs type alias.

Introduction

An interface describes the shape of objects in TypeScript: which properties exist, their types, and which are optional or read-only. It exists only for the type checker. Nothing about an interface survives compilation, so it cannot validate data at runtime, cannot be used with instanceof, and adds no code to your bundle.

That compile-time-only nature explains most of what follows. TypeScript checks interfaces structurally: a value satisfies User if it has the right properties, regardless of where it came from or whether it was ever declared as a User. This makes interfaces flexible, and it is also the root of the surprises covered below, such as excess property checks that fire only sometimes, and data from fetch that is typed as a User but is actually something else.


Interface basics

Declaration

An interface lists the properties an object must have and their types:

// User interface — defines the object "contract"
interface User {
    id: string;
    name: string;
    age: number;
    email: string;
}
// ✅ Valid: all required properties provided
const user: User = {
    id: "U001",
    name: "Alice",
    age: 25,
    email: "[email protected]"
};
// ❌ Missing properties
// const user2: User = { id: "U002", name: "Bob" };
// ❌ Wrong types
// const user3: User = { ..., age: "25", ... };
// ❌ Unknown extra properties (in object literals with explicit type)
// const user4: User = { ..., phone: "..." };

What interfaces give you:

  1. Type checking: validate object structure at compile time
  2. Autocomplete: IDE suggests property names
  3. Documentation: the shape is explicit
  4. Refactoring: rename or change types with confidence

Optional properties

Use ? for optional properties:

interface User {
    id: string;
    name: string;
    age?: number;
    email?: string;
}
// ✅ OK without optional fields
const user1: User = {
    id: "U001",
    name: "Alice"
};
// ✅ OK with optional fields
const user2: User = {
    id: "U002",
    name: "Bob",
    age: 30,
    email: "[email protected]"
};
function printAge(user: User) {
    if (user.age !== undefined) {
        console.log(user.age.toFixed(0));
    }
    console.log(user.age?.toFixed(0));
    const age = user.age ?? 0;
    console.log(age);
}
printAge(user1);
printAge(user2);

Why optional properties help:

  • Flexible shapes
  • Partial updates
  • APIs where some fields may be absent

Readonly properties

readonly marks properties that must not be reassigned after initialization:

interface User {
    readonly id: string;
    name: string;
    age: number;
}
const user: User = {
    id: "U001",
    name: "Alice",
    age: 25
};
user.name = "Bob";  // ✅
user.age = 30;      // ✅
// user.id = "U002";   // ❌ error
interface Post {
    readonly id: string;
    readonly createdAt: Date;
    title: string;
    content: string;
    updatedAt: Date;
}
const post: Post = {
    id: "POST001",
    createdAt: new Date(),
    title: "First post",
    content: "Body",
    updatedAt: new Date()
};
post.title = "Updated title";
post.content = "Updated body";
post.updatedAt = new Date();

readonly vs const:

  • readonly: property on an object
  • const: binding for a variable
const user: User = { id: "U001", name: "Alice", age: 25 };
// user cannot be reassigned
// user.id cannot be reassigned

Two limits of readonly are worth knowing. It is shallow: readonly tags: string[] stops you from assigning a new array but not from calling post.tags.push("x"); use readonly string[] (or ReadonlyArray<string>) for that. And it exists only at compile time: the property is an ordinary writable property in the emitted JavaScript, and a value typed as User can be passed to a function that takes a mutable type with the same shape, which can then modify it. Treat readonly as documentation the compiler enforces in your own code, not as runtime protection (Object.freeze does that).

Excess property checks

interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2, z: 3 };   // ❌ 'z' does not exist in type 'Point'
const tmp = { x: 1, y: 2, z: 3 };
const q: Point = tmp;                    // ✅ no error

This inconsistency surprises everyone at first. TypeScript is structurally typed, so any value with at least x and y is a valid Point, and the second assignment is fine. But when you write an object literal directly where a Point is expected, the extra property can only be a mistake (usually a typo in an optional property name, like colour instead of color), so the compiler reports it. The check applies only to fresh literals. That is why a misspelled option in a configuration object passed inline is caught, while the same object built in a variable first slips through.


Function types in interfaces

Methods

interface Calculator {
    add(a: number, b: number): number;
    subtract(a: number, b: number): number;
    multiply?(a: number, b: number): number;
}
const calc: Calculator = {
    add(a, b) {
        return a + b;
    },
    subtract(a, b) {
        return a - b;
    }
};
console.log(calc.add(10, 5));
console.log(calc.subtract(10, 5));

Call signatures

interface MathOperation {
    (a: number, b: number): number;
}
const add: MathOperation = (a, b) => a + b;
const multiply: MathOperation = (a, b) => a * b;
console.log(add(10, 5));
console.log(multiply(10, 5));

Constructor types

interface ClockConstructor {
    new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
    tick(): void;
}
class DigitalClock implements ClockInterface {
    constructor(h: number, m: number) {}
    tick() {
        console.log("beep beep");
    }
}
function createClock(
    ctor: ClockConstructor,
    hour: number,
    minute: number
): ClockInterface {
    return new ctor(hour, minute);
}
const clock = createClock(DigitalClock, 12, 17);
clock.tick();

Index signatures

String index

interface StringMap {
    [key: string]: string;
}
const colors: StringMap = {
    red: "#FF0000",
    green: "#00FF00",
    blue: "#0000FF"
};
console.log(colors["red"]);
console.log(colors.green);
console.log(colors.purple);   // type says string, value is undefined

An index signature tells the compiler that every string key maps to a string, so colors.purple has type string even though the object has no such key and the value at runtime is undefined. Calling colors.purple.toUpperCase() compiles and throws. Enabling noUncheckedIndexedAccess in tsconfig.json makes index reads return string | undefined, which forces a check. When the set of keys is known, Record<"red" | "green" | "blue", string> is more precise than an index signature, and when keys are dynamic, a Map<string, string> makes “might be missing” explicit through get().

Numeric index

interface NumberArray {
    [index: number]: string;
}
const fruits: NumberArray = ["apple", "banana", "orange"];
console.log(fruits[0]);
console.log(fruits[1]);

Mixed constraints

interface Dictionary {
    [key: string]: string | number;
    length: number;
}
const dict: Dictionary = {
    name: "Alice",
    age: 25,
    length: 2
};

Extending interfaces

extends

interface Person {
    name: string;
    age: number;
}
interface Employee extends Person {
    employeeId: string;
    department: string;
}
const employee: Employee = {
    name: "Alice",
    age: 30,
    employeeId: "E001",
    department: "Engineering"
};

Multiple inheritance

interface Timestamped {
    createdAt: Date;
    updatedAt: Date;
}
interface Identifiable {
    id: string;
}
interface User extends Identifiable, Timestamped {
    name: string;
    email: string;
}
const user: User = {
    id: "U001",
    name: "Alice",
    email: "[email protected]",
    createdAt: new Date(),
    updatedAt: new Date()
};

Interface merging

Declaration merging

interface User {
    name: string;
}
interface User {
    age: number;
}
const user: User = {
    name: "Alice",
    age: 25
};

Augmenting globals

// In a script file (no import/export), this merges with the global Window:
interface Window {
    myCustomProperty: string;
}

// In a module file (any import or export), you must say "global" explicitly:
export {};
declare global {
    interface Window {
        myCustomProperty: string;
    }
}
window.myCustomProperty = "Hello!";

Declaration merging is how libraries let you extend their types, for example adding a user property to Express’s Request, or custom environment variables to NodeJS.ProcessEnv. The trap is the file kind. As soon as a file has a top-level import or export, it is a module, and an interface Window inside it declares a new, local interface that merges with nothing. The compiler then reports “Property ‘myCustomProperty’ does not exist on type ‘Window’” at every use, which looks like the augmentation is being ignored. Wrap it in declare global { ... } (for globals) or declare module "express-serve-static-core" { ... } (for a library’s module), and make sure the file is included by your tsconfig.json.

Merging is also a reason some teams prefer type for their own models: two unrelated interface User declarations in the same scope, such as one in your code and one from a global .d.ts, silently merge into one type with both sets of properties instead of producing an error.


Classes and interfaces

implements

interface Animal {
    name: string;
    makeSound(): void;
}
class Dog implements Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    makeSound() {
        console.log("Woof!");
    }
}
const dog = new Dog("Buddy");
dog.makeSound();

Multiple interfaces

interface Flyable {
    fly(): void;
}
interface Swimmable {
    swim(): void;
}
class Duck implements Flyable, Swimmable {
    fly() {
        console.log("Flying!");
    }
    swim() {
        console.log("Swimming!");
    }
}
const duck = new Duck();
duck.fly();
duck.swim();

implements is only a check: it verifies that the class is assignable to the interface and does nothing else. It does not add properties, does not give the class’s methods their parameter types, and leaves no trace at runtime:

interface Handler { handle(msg: string): void }
class H implements Handler {
    handle(msg) { }   // ❌ with strict: Parameter 'msg' implicitly has an 'any' type
}

Many people expect msg to be inferred as string from the interface. It is not, so you must repeat the parameter types in the class. And because interfaces are erased during compilation, you cannot check value instanceof Animal for an interface. Runtime checks need a class, a discriminant property, or a user-defined type guard (function isAnimal(x: unknown): x is Animal).


Interface vs type alias

Comparison

FeatureInterfaceType alias
Object shapes✅✅
Union / intersection❌ (use type)✅
Extensionextends&
Declaration merging✅❌
Primitive aliases❌✅

Examples

interface User {
    name: string;
}
interface User {
    age: number;
}
type Person = {
    name: string;
};
type ID = string | number;
type Status = "active" | "inactive";
type Employee = Person & {
    employeeId: string;
};

The difference between extends and & shows up when properties conflict:

interface A { id: string }
interface B extends A { id: number }        // ❌ Interface 'B' incorrectly extends interface 'A'

type C = { id: string } & { id: number };   // no error here...
const c: C = { id: "x" };                   // ...but id is 'never', so nothing is assignable

extends checks compatibility at the declaration and reports the conflict right there. An intersection silently produces never for the conflicting property, and the error appears later, far from the cause, when you try to create a value. The TypeScript team also recommends interfaces with extends for large object hierarchies because the compiler caches interface relationships, which can make type checking noticeably faster in big codebases than deeply nested intersections.

A reasonable rule that many teams use: interface for object shapes that other code extends or implements (and for library augmentation), type for unions, tuples, mapped and conditional types, and function types. Within one codebase, picking one convention for plain object models matters more than which one you pick.

In my experience, the interface-related bugs that reach code review are rarely about syntax. They are an index signature hiding a missing key, an augmentation that silently did nothing because the file became a module, or an intersection that turned into never after someone changed one side. Each of these produces an error far from its cause, which is why the compiler options (strict, noUncheckedIndexedAccess) and the extends-over-& habit are worth more than memorizing the feature list.


Hands-on examples

Example 1: API response

interface ApiResponse<T> {
    success: boolean;
    data: T;
    error?: string;
    timestamp: Date;
}
interface User {
    id: string;
    name: string;
    email: string;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
    try {
        const response = await fetch(`/api/users/${id}`);
        const data = await response.json();
        return {
            success: true,
            data,
            timestamp: new Date()
        };
    } catch (error) {
        return {
            success: false,
            data: null as any,
            error: "Request failed",
            timestamp: new Date()
        };
    }
}

This example is typical, and it has two problems worth recognizing. First, fetch does not reject on HTTP errors. A 404 or 500 response resolves normally, so the catch branch only runs for network failures or invalid JSON, and an error body from the server is returned as success: true with data holding something that is not a User. Check response.ok before parsing. Second, response.json() returns any, and assigning it to data means the interface is a promise the compiler cannot verify. If the API renames email to emailAddress, everything still compiles and user.email is undefined at runtime. For data crossing a trust boundary, validate it with a schema library (Zod, Valibot) or a hand-written type guard, and derive the type from the schema.

The data: null as any in the error branch is also a sign that the interface does not describe the real states. A discriminated union says it directly and lets the compiler force callers to check:

type ApiResult<T> =
    | { success: true; data: T; timestamp: Date }
    | { success: false; error: string; timestamp: Date };

async function fetchUserSafe(id: string): Promise<ApiResult<User>> {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
        return { success: false, error: `HTTP ${response.status}`, timestamp: new Date() };
    }
    return { success: true, data: (await response.json()) as User, timestamp: new Date() };
}

const result = await fetchUserSafe("U001");
if (result.success) {
    console.log(result.data.name);   // data is only accessible here
}

This is one of the places where type is the natural choice: an interface cannot express “one of these two shapes”.

Example 2: Form validation

interface FormField {
    value: string;
    error: string | null;
    touched: boolean;
}
interface LoginForm {
    email: FormField;
    password: FormField;
}
const form: LoginForm = {
    email: {
        value: "",
        error: null,
        touched: false
    },
    password: {
        value: "",
        error: null,
        touched: false
    }
};
function validateEmail(email: string): string | null {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return regex.test(email) ? null : "Invalid email format";
}
form.email.value = "[email protected]";
form.email.error = validateEmail(form.email.value);
form.email.touched = true;

Example 3: Event handlers

interface ClickEvent {
    x: number;
    y: number;
    button: "left" | "right";
}
interface EventHandler<T> {
    (event: T): void;
}
const handleClick: EventHandler<ClickEvent> = (event) => {
    console.log(`Click: (${event.x}, ${event.y}), button: ${event.button}`);
};
handleClick({ x: 100, y: 200, button: "left" });

Next in the series