TL;DR
Master TypeScript interfaces and type aliases. Learn extending, intersection types, declaration merging, and when to use each.
Key concepts
- TypeScript interfaces
- type aliases TypeScript
- interface vs type
- TypeScript intersection types
Interfaces and Types
TypeScript gives you two powerful tools for defining data shapes: interfaces and type aliases. Understanding when to use each is essential for clean, maintainable code.
Interfaces vs Type Aliases
Both can describe object shapes, but they differ in important ways.
// Interface
interface User {
id: number;
name: string;
email: string;
}
// Type alias
type Product = {
id: number;
name: string;
price: number;
};
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
const product: Product = { id: 101, name: "Keyboard", price: 79.99 };
console.log(`User: ${user.name} (${user.email})`);
console.log(`Product: ${product.name} - $${product.price}`);
// Type aliases can represent primitives, unions, and tuples
type ID = string | number;
type Coordinate = [number, number];
type Status = "active" | "inactive" | "pending";
const userId: ID = "abc-123";
const point: Coordinate = [10, 20];
const userStatus: Status = "active";
console.log(`ID: ${userId}, Point: (${point[0]}, ${point[1]}), Status: ${userStatus}`);
TypeScript uses structural typing: a value fits a type when it has the required shape, not because it was declared with that type's name. That rule has one sharp exception worth knowing before you go further — a fresh object literal assigned straight into a typed slot gets an excess property check, so extra keys are rejected even though they would satisfy the shape structurally.
Predict
Both assignments hand an object with an extra z key to a Point slot — one through a variable, one as a fresh literal. Under tsc --strict, exactly one line is a compiler error. Which one, and what is the first thing you observe? (The playground strips types and would run this either way — reason about the compiler, not the run.)
interface Point { x: number; y: number; }
const raw = { x: 1, y: 2, z: 3 };
const p1: Point = raw; // line A: assigned via a variable
const p2: Point = { x: 1, y: 2, z: 3 }; // line B: a fresh object literal
console.log(p1.x, p2.y);Extending Interfaces
Interfaces can extend other interfaces to build complex types from simpler ones.
interface Animal {
name: string;
age: number;
}
interface Pet extends Animal {
owner: string;
vaccinated: boolean;
}
interface Dog extends Pet {
breed: string;
tricks: string[];
}
const myDog: Dog = {
name: "Max",
age: 3,
owner: "Alice",
vaccinated: true,
breed: "Golden Retriever",
tricks: ["sit", "shake", "roll over"]
};
console.log(`${myDog.name} is a ${myDog.breed}`);
console.log(`Owner: ${myDog.owner}, Age: ${myDog.age}`);
console.log(`Tricks: ${myDog.tricks.join(", ")}`);
// Extending multiple interfaces
interface Serializable {
toJSON(): string;
}
interface Printable {
display(): string;
}
interface TextDocument extends Serializable, Printable {
title: string;
content: string;
}
const doc: TextDocument = {
title: "Meeting Notes",
content: "Discussed quarterly goals.",
toJSON() {
return JSON.stringify({ title: this.title, content: this.content });
},
display() {
return `${this.title}: ${this.content}`;
}
};
console.log(`\nDocument: ${doc.display()}`);
console.log(`JSON: ${doc.toJSON()}`);
Intersection Types
Type aliases use & to combine types, similar to interface extension.
type HasName = { name: string };
type HasAge = { age: number };
type HasEmail = { email: string };
// Combine with intersection
type Person = HasName & HasAge & HasEmail;
const person: Person = {
name: "Bob",
age: 28,
email: "bob@example.com"
};
console.log(`${person.name}, age ${person.age}, email: ${person.email}`);
// Intersection with inline types
type Employee = Person & {
department: string;
salary: number;
};
const employee: Employee = {
name: "Carol",
age: 35,
email: "carol@company.com",
department: "Engineering",
salary: 95000
};
console.log(`\n${employee.name} works in ${employee.department}`);
console.log(`Salary: $${employee.salary}`);
// Generic response wrapper
type ApiResponse<T> = {
data: T;
status: number;
timestamp: string;
};
type UserData = { id: number; username: string };
type UserResponse = ApiResponse<UserData>;
const response: UserResponse = {
data: { id: 1, username: "alice" },
status: 200,
timestamp: new Date().toISOString()
};
console.log(`\nAPI Response: status ${response.status}`);
console.log(`User: ${response.data.username}`);
Recall
Without scrolling up: you have defined an interface User, and now you want a value that holds many users — a growable, ordered list of them. Reaching back to 05-data-structures, which type describes that, and how do you write it?
Declaration Merging
Interfaces support declaration merging — declaring the same interface name twice merges them. Type aliases cannot do this.
// Declaration merging
interface Config {
apiUrl: string;
timeout: number;
}
interface Config {
retryCount: number;
debug: boolean;
}
// Merged Config has all four properties
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retryCount: 3,
debug: false
};
console.log(`API URL: ${config.apiUrl}`);
console.log(`Timeout: ${config.timeout}ms`);
console.log(`Retries: ${config.retryCount}`);
console.log(`Debug: ${config.debug}`);
Optional and Readonly Properties
interface UserProfile {
readonly id: number;
name: string;
email: string;
bio?: string;
avatar?: string;
readonly createdAt: string;
}
const profile: UserProfile = {
id: 1,
name: "Diana",
email: "diana@example.com",
bio: "TypeScript enthusiast",
createdAt: "2024-01-15"
};
profile.name = "Diana Smith";
// profile.id = 2; // Error: Cannot assign to read-only property
console.log(`ID: ${profile.id} (readonly)`);
console.log(`Name: ${profile.name}`);
console.log(`Bio: ${profile.bio}`);
console.log(`Avatar: ${profile.avatar ?? "not set"}`);
// Index signatures for dynamic keys
interface StringMap {
[key: string]: string;
}
const headers: StringMap = {
"Content-Type": "application/json",
"Authorization": "Bearer token123"
};
Object.entries(headers).forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
When the compiler catches a typo (a no-run demo)
The excess property check from the Predict above is not just pedantry — it is how a misspelled key gets caught the instant you write it. This fence is marked no-run because its whole point is to fail to compile: the runner strips the types and would happily run the buggy object, which would defeat the lesson.
interface UserProfile {
id: number;
name: string;
email: string;
}
// A typo: "emial" instead of "email", on a fresh object literal.
const profile: UserProfile = {
id: 1,
name: "Diana",
emial: "diana@example.com",
};
console.log(profile.name);
Under tsc --strict this fails with:
error TS2561: Object literal may only specify known properties, but 'emial' does not exist in type 'UserProfile'. Did you mean to write 'email'?
That error is the excess property check earning its keep: emial is not a known key of UserProfile, so the fresh literal is rejected — and the compiler is even sharp enough to suggest the key you meant. The runner would strip the annotation and print Diana while profile.email was silently undefined; the compiler stops you before that ever runs.
Try It Yourself
Reading about interfaces and intersections is not the same as modelling with them. This is a build task: a small program that reports its own pass/fail. You are given a Lesson interface (an Entity extended with lesson fields), a Timestamps type, and a StoredLesson that intersects the two — the exact domain-model shapes the tracker capstone stores. Three functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
The three functions reuse exactly what this lesson taught: building an intersection value that satisfies both halves at once, reading only the fields a type actually has, and folding an array of the interface into a total. The starter has the data, the stubs, and the checks — you write only the logic inside each function.
Build
Finish the build. Three functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// The domain shapes are given. Do NOT change these.
interface Entity {
id: string;
title: string;
}
interface Lesson extends Entity {
kind: "lesson";
minutesSpent: number;
}
type Timestamps = { createdAt: string; updatedAt: string };
// A stored lesson is a Lesson AND timestamps — an intersection of both.
type StoredLesson = Lesson & Timestamps;
// The raw rows the tracker starts from. Do NOT change this array.
const rows: Lesson[] = [
{ kind: "lesson", id: "l1", title: "Intro to types", minutesSpent: 25 },
{ kind: "lesson", id: "l2", title: "Interfaces", minutesSpent: 40 },
];
// TODO 1: build a StoredLesson from a Lesson by intersecting in timestamps.
// Return the lesson's own fields PLUS createdAt and updatedAt (both set to
// the given `when`). Because StoredLesson = Lesson & Timestamps, the result
// must carry every field of both halves.
// Example intersection value: { ...someLesson, createdAt: "…", updatedAt: "…" }
// stamp(rows[0], "2024-01-01").createdAt -> "2024-01-01"
function stamp(lesson: Lesson, when: string): StoredLesson {
// your code here
return { ...lesson, createdAt: "", updatedAt: "" }; // replace this
}
// TODO 2: build the summary line for one stored lesson. Read only fields that
// exist on StoredLesson (id, title, minutesSpent, createdAt). Return exactly:
// '<id>: <title> (<minutesSpent>m) @<createdAt>'
// summarize(stamp(rows[0], "2024-01-01")) -> 'l1: Intro to types (25m) @2024-01-01'
function summarize(lesson: StoredLesson): string {
// your code here
return ""; // replace this
}
// TODO 3: total the minutes across a list of lessons.
// totalMinutes(rows) -> 65
function totalMinutes(lessons: Lesson[]): number {
// your code here
return 0; // replace this
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const storedFirst = stamp(rows[0], "2024-01-01");
assert.strictEqual(storedFirst.createdAt, "2024-01-01", "TODO 1: stamp should set createdAt from its when argument");
assert.strictEqual(storedFirst.updatedAt, "2024-01-01", "TODO 1: stamp should set updatedAt from its when argument too");
assert.strictEqual(storedFirst.minutesSpent, 25, "TODO 1: stamp must keep the lesson's own fields alongside the timestamps");
assert.strictEqual(
summarize(storedFirst),
"l1: Intro to types (25m) @2024-01-01",
"TODO 2: summarize should render the stored lesson line",
);
assert.strictEqual(totalMinutes(rows), 65, "TODO 3: totalMinutes should add every lesson's minutesSpent");
console.log("All checks passed.");
console.log("Stored:", summarize(storedFirst));
console.log("Total minutes:", totalMinutes(rows));Expected output: All checks passed.
Stored: l1: Intro to types (25m) @2024-01-01
Total minutes: 65
Once it passes, try two variations and predict each before running:
- Count instead of sum. In
totalMinutes, changesum + l.minutesSpenttosum + 1so it counts lessons rather than adding their minutes. Predict which check fails first before running.totalMinutes(rows)now returns2(two rows), so TODO 3's check fires immediately withAssertionError: TODO 3: totalMinutes should add every lesson's minutesSpentand2 !== 65— the earlier checks still pass, so this is the first failure. An instructive assert failure isolating summing a field from counting elements. - Echo a second stored lesson. After the checks pass, add
console.log("Second stored:", summarize(stamp(rows[1], "2024-02-02")));below the existing logs. Predict the new line before running. You getSecond stored: l2: Interfaces (40m) @2024-02-02— the second row stamped with a different date and summarised the same way. This changes the echoed output, not any check.
Capstone milestone
Milestone — the domain model. The tracker's entities are interfaces (or type aliases) with precise fields: an Entity with id and title, extended into a Lesson, and intersected with timestamps into the record actually stored. The StoredLesson you just built is that shape. Confirm you can model an entity with interfaces, extension, and intersection.
Hint: This is the domain-model milestone shared with *Data Structures* (typed collections) and *Enums And Constants* (literal-union tags). Here, interfaces and intersections give each tracker entity its precise shape — the foundation every later layer stores, validates, and reports on.
- Defined an entity as an interface (Entity) and extended it into a richer shape (Lesson)
- Combined two shapes with an intersection type (Lesson & Timestamps)
- Built a value that satisfies the whole intersection (every field of both halves present)
- Read only fields the type actually declares — no extra keys, no missing ones
Key Takeaways
- Interfaces define object shapes and support declaration merging
- Type aliases can represent any type: objects, primitives, unions, tuples, intersections
- Use
extendsfor interface hierarchies and&for type intersections - Declaration merging is unique to interfaces — useful for extending third-party types
- Both support optional (
?) andreadonlymodifiers - Prefer interfaces for public APIs, type aliases for unions and complex compositions
Pro Tip: Start with interfaces for object shapes that might be extended or implemented by classes. Use type aliases for unions, intersections, and computed types. When in doubt, start with an interface — you can always switch later.
Next Steps
You've seen how interfaces and type aliases define fixed shapes. But many of those shapes need a fixed set of named values — an order status, a user role, a theme. Next, you'll learn enums and const assertions: two ways to define a closed set of constants that are readable, misspell-proof, and fully type-checked.
Ready to continue? Head to Enums and Constants!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.