Technology  /  TypeScript

🔷 TypeScript 16 guides · updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit — TypeScript taught the way it actually behaves at compile time and at runtime.

Interfaces in TypeScript: Defining Contracts for Objects and Classes

An interface looks like it’s declaring a new type the way a class declares a new constructor — but it isn’t. An interface is purely a compile-time description of a shape: “anything passed here must have these properties, with these types.” Nothing about that description survives into the compiled JavaScript. Once that clicks, the rest of how interfaces behave — why you can’t instanceof one, why unrelated objects satisfy the same interface, why two interfaces with the same name silently merge — stops being surprising and starts being predictable.


Defining an Interface

interface User {
id: number;
name: string;
email: string;
}
function sendWelcomeEmail(user: User) {
console.log(`Sending email to ${user.email}`);
}
const newUser: User = { id: 1, name: "Alice", email: "alice@example.com" };
sendWelcomeEmail(newUser);

Any object with an id: number, name: string, and email: string satisfies User — including one that was never explicitly typed as User at all. This is structural typing (covered in depth in how TypeScript works) applied specifically to interfaces: the interface is a shape check, not a declared-relationship check.

function fetchFromApi() {
return { id: 1, name: "Bob", email: "bob@example.com", role: "admin" };
}
sendWelcomeEmail(fetchFromApi()); // Works — the returned object has everything User needs, plus extra

Optional and Readonly Properties

interface Product {
readonly sku: string;
name: string;
discount?: number;
}
const product: Product = { sku: "ABC123", name: "Widget" };
product.name = "Deluxe Widget"; // fine
product.sku = "XYZ789"; // Error: Cannot assign to 'sku' because it is a read-only property

readonly prevents reassignment after initial creation — it’s a compile-time-only guarantee (again, type erasure: there’s no Object.freeze() happening at runtime unless you add it yourself), but it catches an entire class of “this field should never change after construction” mistakes before they ship. discount?: number marks a property as optional — its actual type inside consuming code becomes number | undefined, and you have to account for the missing case:

function getFinalPrice(product: Product, basePrice: number): number {
return basePrice - (product.discount ?? 0);
}

A common trap: readonly only prevents reassigning the property itself — it does nothing to prevent mutating a nested object or array the property points to.

interface Cart {
readonly items: string[];
}
const cart: Cart = { items: ["apple"] };
cart.items = ["banana"]; // Error — reassigning the property
cart.items.push("banana"); // No error! The array itself is still mutable

For genuinely deep immutability, readonly combined with ReadonlyArray<T> (or readonly string[]) is needed on the nested type too — plain readonly only protects the one level it’s declared at.


Extending Interfaces

interface Animal {
name: string;
sound(): string;
}
interface Pet extends Animal {
owner: string;
}
const dog: Pet = {
name: "Rex",
owner: "Alice",
sound() { return "Woof"; },
};
// Extending multiple interfaces at once
interface Timestamped {
createdAt: Date;
}
interface Auditable extends Timestamped, Pet {
lastModifiedBy: string;
}

extends composes interfaces the way inheritance composes classes, except there’s no runtime relationship being created — Pet extends Animal simply means “a Pet must have every property Animal requires, plus its own additions.” Because this is purely structural, an object satisfying Pet doesn’t need to have been built through any particular constructor chain; it just needs the right shape.


Interfaces and Classes Together

interface Shape {
area(): number;
perimeter(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
area(): number {
return this.width * this.height;
}
perimeter(): number {
return 2 * (this.width + this.height);
}
}
class Circle implements Shape {
constructor(private radius: number) {}
area(): number {
return Math.PI * this.radius ** 2;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
}
function describeShape(shape: Shape) {
console.log(`Area: ${shape.area().toFixed(2)}, Perimeter: ${shape.perimeter().toFixed(2)}`);
}
describeShape(new Rectangle(4, 6));
describeShape(new Circle(3));

implements is a compile-time promise: “this class guarantees it has everything the interface requires.” If Rectangle were missing perimeter(), TypeScript would refuse to compile — but at runtime, implements adds nothing; it doesn’t create a marker you can check with instanceof Shape (interfaces have no runtime existence at all, unlike classes). This is the standard pattern for writing code that depends on behavior, not concrete implementation — describeShape never needs to know whether it received a Rectangle or a Circle, only that whatever it received satisfies Shape.

implements (checked at compile time)

implements (checked at compile time)

real runtime class

real runtime class

interface Shape

(compile-time contract, erased at runtime)

class Rectangle

class Circle

new Rectangle(4,6)

new Circle(3)


Function and Callable Interfaces

interface SearchFunction {
(query: string, limit?: number): string[];
}
const search: SearchFunction = (query, limit = 10) => {
return database.filter(item => item.includes(query)).slice(0, limit);
};
// Interfaces can also describe a callable AND have properties — hybrid types
interface Counter {
(): number; // callable
reset(): void; // method
current: number; // property
}
function createCounter(): Counter {
let count = 0;
const counter = (() => ++count) as Counter;
counter.reset = () => { count = 0; };
counter.current = count;
return counter;
}

Hybrid interfaces (callable plus properties) show up most often when typing legacy JavaScript libraries that attach properties directly to a function — jQuery’s $ being the classic example ($() is callable, but $.ajax() also exists as a property on it).


Index Signatures

interface StringDictionary {
[key: string]: string;
}
const translations: StringDictionary = {
hello: "Hola",
goodbye: "Adiós",
};
translations["welcome"] = "Bienvenido"; // fine — any string key works
translations[404] = "not found"; // Error: not a string value

An index signature describes objects used as dynamic lookups, where the exact set of keys isn’t known ahead of time — as opposed to a fixed-shape interface like User, where every property is named explicitly. Mixing named properties with an index signature requires every named property to be compatible with the index signature’s value type:

interface Config {
[key: string]: string | number;
environment: string; // fine — string is compatible with "string | number"
port: number; // fine — number is compatible with "string | number"
}

Declaration Merging — A Behavior Unique to Interfaces

interface Window {
myCustomProperty: string;
}
// Elsewhere in the codebase — perhaps a different file entirely
interface Window {
anotherProperty: number;
}
// TypeScript merges both declarations automatically:
// Window now requires both myCustomProperty AND anotherProperty

This is the sharpest difference between interface and type (covered in depth in the next guide): declaring the same interface name twice merges the declarations into one combined shape, rather than throwing a “duplicate declaration” error. This isn’t an accident or a footgun by default — it’s the mechanism that lets you add properties to third-party types (like the global Window object, or extending an Express Request object with custom middleware-attached fields) without modifying the original library’s source:

// Extending Express's Request type with a custom property added by your auth middleware
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string };
}
}
}
// Now, anywhere in your app:
app.get("/profile", (req, res) => {
console.log(req.user?.id); // TypeScript knows about `user` without Express's own types being modified
});

Declaration merging is powerful specifically because interfaces are structural descriptions, not concrete implementations — merging two descriptions of the same shape into a combined, stricter description is coherent in a way that merging two class implementations with the same name could never be, which is exactly why type aliases (which can’t be re-declared at all) don’t support this behavior.


Interfaces vs Object Type Literals

interface User {
name: string;
age: number;
}
// Equivalent as an inline type literal:
function greet(user: { name: string; age: number }) { ... }

For a one-off shape used in exactly one place, an inline object type literal is fine and arguably clearer — no need to name and hoist something used once. Reach for a named interface the moment the same shape is used in more than one place, needs to be extended, or needs to appear in error messages and editor tooltips under a meaningful name rather than a sprawling inline literal.


Keeping Interfaces Small and Focused

// One large interface forces every implementer to support everything, even unused capabilities
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class Robot implements Worker {
work() { console.log("Working..."); }
eat() { throw new Error("Robots don't eat"); } // forced to implement something meaningless
sleep() { throw new Error("Robots don't sleep"); }
}
// Splitting into smaller, focused interfaces — implement only what applies
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
class Robot implements Workable {
work() { console.log("Working..."); }
}
class Human implements Workable, Feedable {
work() { console.log("Working..."); }
eat() { console.log("Eating lunch"); }
}

This is the interface segregation principle applied directly to TypeScript: a large interface with unrelated responsibilities forces every implementer to either support capabilities it doesn’t have or throw not-implemented errors for them — both are signs the interface should be split. Smaller, focused interfaces also compose more naturally with extends and with generic constraints (covered in the generics guide), since a function that only needs Workable shouldn’t have to require Feedable too just because one concrete class happens to implement both.

Frequently Asked Questions

Can I check if an object implements an interface at runtime? No — interfaces have zero runtime representation (type erasure), so there’s nothing to check against with instanceof or any other runtime mechanism. If you need a runtime check, either check for the presence of specific properties/methods manually, or use a class (which does generate a real runtime constructor) instead of an interface where runtime identity matters.

Why did my two separately-declared interfaces with the same name silently combine instead of erroring? This is declaration merging, and it’s intentional — it’s the mechanism for augmenting existing types (especially third-party or global ones) without editing their source. If you didn’t intend to merge, the actual bug is usually an accidental name collision between two genuinely unrelated types; renaming one resolves it.

Should every function parameter that’s an object be typed with a named interface? Not necessarily — for a shape used exactly once and unlikely to be reused, an inline type literal is simpler. Name it as an interface once it’s reused across multiple functions, needs extends, or benefits from appearing by name in editor autocomplete and error messages.

Can an interface extend a class? Yes — an interface can extends a class, which creates an interface matching that class’s shape (including private/protected members, which then only other subclasses of that same class can implement). This is a narrower, less common pattern than extending another interface, and mostly useful when modeling a “must be a subtype of this specific class hierarchy” constraint.

What happens if a class doesn’t fully implement an interface? TypeScript refuses to compile — implements is a compile-time-checked promise, and a missing method or property is caught immediately at the class declaration, not silently allowed through and discovered later when something calls the missing method at runtime.


What’s Next

Interfaces give you a way to describe object and class shapes as reusable, mergeable contracts — the foundation the rest of TypeScript’s type system builds on. Next: Type Aliases vs Interfaces, where we settle the question every TypeScript developer eventually asks: when the two seem to do the same thing, which one should you actually reach for?