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.
What You Already Know
By the end of this lesson you will model a stored learning record end to end: an Entity with an id and a title, extended into a Lesson, then intersected with timestamps into the StoredLesson the tracker actually keeps — and you will be able to say why a misspelled key is caught the moment you write it rather than three screens later.
This is the domain-model milestone. Every later layer of the tracker — the storage service, the validation boundary, the console report — reads fields off these shapes, so getting them precise here is what makes the rest of the project type-check at all. You arrive already able to build the collections that hold these records: Data Structures gave you the typed array and the object literal, and you used shapes like these there without naming them. What is new is giving each shape a name the compiler enforces, and combining named shapes with extends and &.
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;
}
// TODO before you read the next four lines: Animal named the fields every
// animal has, and Pet extended it with the two an owned animal adds. Write the
// third level yourself — Dog adds a breed and a list of tricks — then compare
// it with what follows. Note what Dog does NOT repeat: name, age, owner and
// vaccinated all arrive through the extends chain.
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}`);
Notice the shape choice in that last block: ApiResponse<T> is a type alias, not an interface, because its job is to be composed — wrapped around a payload type and intersected with other shapes. Interfaces are the better default when a shape will be extended or implemented by a class; type aliases are the better default when a shape will be combined with &, unioned with |, or parameterised. That is the decision behind the two syntaxes, and it is why this lesson reaches for interface on the Animal/Pet/Dog hierarchy and for type on HasName & HasAge & HasEmail.
Now work the next shape out yourself, and actually write it down before you scroll. Given type HasName = { name: string } and type HasTimestamps = { createdAt: string }, write the one line that names a shape carrying both, then a value that satisfies it. The answer is not in the next paragraph — it is below the retrieval question, so you have to commit to something first.
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 Data Structures, which type describes that, and how do you write it?
Now the shape you were asked to write above:
type HasName = { name: string };
type HasTimestamps = { createdAt: string };
type Stamped = HasName & HasTimestamps;
const entry: Stamped = { name: "Types", createdAt: "2024-01-15" };
console.log(`${entry.name} created ${entry.createdAt}`);
If you wrote type Stamped = HasName & HasTimestamps; and then found you had to supply name and createdAt in the value, you have the intersection rule in hand: & demands every field of every half, never a choice between them. Drop either field from entry and the compiler rejects it. That is the rule the build task at the end leans on, and it is the last worked example you get before you write one.
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; // rejected by the compiler — the exact error is quoted below
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}`);
});
What readonly actually promises (a no-run demo)
The commented-out assignment above is the point of the modifier, but a comment cannot show you the error. This fence is marked no-run because its whole job is to fail to compile: the runner strips the types and would perform the assignment happily, which — as you are about to see — is the whole story here.
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.id = 2;
console.log(`ID: ${profile.id}`);
Under tsc --strict this fails with:
error TS2540: Cannot assign to 'id' because it is a read-only property.
Now the runtime shadow. readonly is a TypeScript modifier: it is erased along with the types, so nothing stops the write once the program is running. Object.freeze is a different mechanism — it is a real runtime call, and the engine enforces it:
interface Reading {
readonly id: number;
label: string;
}
// readonly is a compile-time promise. tsx erases it, so the write lands.
const loose = { id: 1, label: "first" } as Reading;
(loose as { id: number }).id = 2;
console.log(`readonly id after the write: ${loose.id}`);
// Object.freeze is a runtime mechanism. The engine enforces it.
const frozen = Object.freeze({ id: 1, label: "first" });
(frozen as { id: number }).id = 2;
console.log(`frozen id after the write: ${frozen.id}`);
Running that prints readonly id after the write: 2 and then frozen id after the write: 1. The two casts are there only to get past the compiler so the runtime behaviour is visible; the point is that readonly guarantees nothing after compilation, and if you need the guarantee to survive into the running program, Object.freeze is the tool that supplies it.
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.
Debug
Rows arrive from an external feed carrying a minutes key, and this converts them into the StoredLesson shape the tracker keeps — which spells the field minutesSpent. It compiles, it runs, and it fails its own check with a number that should not be possible. Commit a hypothesis about what the reduce is actually adding up, then fix it so the total comes out as 65.
import assert from "node:assert";
interface StoredLesson {
id: string;
title: string;
minutesSpent: number;
}
// Rows arrive from an external source with a spare "minutes" key.
const incoming = [
{ id: "l1", title: "Intro to types", minutes: 25 },
{ id: "l2", title: "Interfaces", minutes: 40 },
];
function store(rows: { id: string; title: string; minutes: number }[]): StoredLesson[] {
return rows.map((row) => ({ ...row } as unknown as StoredLesson));
}
const stored = store(incoming);
const total = stored.reduce((sum, lesson) => sum + lesson.minutesSpent, 0);
assert.strictEqual(total, 65, "expected 65 stored minutes, got " + total);
console.log("Total stored minutes:", total);Expected output:
Total stored minutes: 65
The puzzle below builds a stored record out of three shapes. Here they are — this fence is marked no-run because it only declares types, so there is nothing for it to print:
interface Entity {
id: string;
title: string;
}
interface Lesson extends Entity {
kind: "lesson";
minutesSpent: number;
}
// Only the created stamp here, so the puzzle stays five lines long.
// The build task at the end of the lesson uses the fuller two-field version.
type StoredLesson = Lesson & { createdAt: string };
Note the deliberate difference: the build task at the end of this lesson defines StoredLesson as Lesson & Timestamps where Timestamps carries both createdAt and updatedAt. The puzzle uses the one-field version above so that a single spread completes the value in one line. Everything else — Entity, Lesson, and the intersection rule — is identical.
Arrange the code
These five lines build up the tracker's stored record one layer at a time - a bare entity, then the lesson that extends it, then the stored form that intersects in a timestamp, then the report line, then the log. The pieces are shuffled. Put them back in an order that runs, then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?
const stored: StoredLesson = { ...lesson, createdAt: "2024-01-01" };console.log(line);const lesson: Lesson = { ...base, kind: "lesson", minutesSpent: 40 };const base: Entity = { id: "l1", title: "Interfaces" };const line = `${stored.id}: ${stored.title} (${stored.minutesSpent}m) @${stored.createdAt}`;
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
Transfer
TypeScript decides whether a value fits an interface by looking at the value's shape. Java and C# decide by looking at what the class was declared to implement - a type there fits an interface only if its declaration says so by name. Same idea, different rule. Suppose you declare two interfaces with identical fields, Meters and Seconds, each holding one number. A function takes a Meters. What does each language do when you hand it a Seconds, and what does that tell you about carrying the habit across?
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!
Next lesson
Enums And Constants
Learn TypeScript enums and const assertions to create named, type-safe constants. Replace magic strings and numbers in your code.
25 min