Lesson 2

Interfaces & type aliases

Home › Courses › TypeScript › Lesson 2
🔷 Lesson 2 · 15 minutes

Interfaces & type aliases.
Describe the shape of your data.

Annotations work for primitives, but real data has shape — objects with many properties, some optional, some readonly. Interfaces and type aliases give that shape a name. This lesson covers both, the syntax for optional and readonly fields, index signatures for dynamic keys, and the one real difference between them (declaration merging) that decides which to reach for in practice.

~15 minutes Beginner TypeScript 5 · interfaces
TypeScript path Interface Optional & readonly Extend & compose Type alias interface vs type Challenge

An interface describes an object's shape: interface User { name: string; age: number }. A type alias names any type: type ID = string | number. Both are erased at compile time — they don't exist in the output. Use interface for object shapes you might extend or that a library might merge; use type for unions, tuples, function signatures, and everything else. Optional properties use ?, readonly fields use readonly, dynamic keys use [key: string]: T.

Before this lesson: complete Lesson 1 — you need to understand annotations and inference first.

🧱 The interface — naming an object shape

If you have three objects that all look the same, give that shape a name.

In lesson 1, you wrote inline object types: { name: string; age: number }. That works once. But if the same shape appears in five places, you don't want to repeat it — you want a name. An interface is exactly that: a named object shape, used as a type anywhere.

user.ts
interface User {
name: string;
age: number;
email: string;
}

const alice: User = {
name: 'Alice',
age: 28,
email: 'alice@example.com'
};

const bob: User = {
name: 'Bob',
age: 34,
email: 'bob@example.com'
};

// ❌ Missing 'email' — compile error before any code runs
// const broken: User = { name: 'X', age: 30 };

function greet(user: User): string {
return ${user.name}, ${user.age};
}

console.log(greet(alice));
// interface: erased, does not exist in output
// : User on const: erased
// : string on return: erased

const alice = {
name: 'Alice',
age: 28,
email: 'alice@example.com'
};

const bob = {
name: 'Bob',
age: 34,
email: 'bob@example.com'
};

function greet(user) {
return ${user.name}, ${user.age};
}

console.log(greet(alice));
Alice, 28

Notice what the compiler caught: broken was missing email, so it never compiled. In plain JavaScript, this bug would have shipped and shown undefined at runtime. That's the trade — a small compile step for a whole class of bugs eliminated.

Named shape

An interface gives a name to an object's shape. Use it as a type anywhere: variable annotations, function parameters, return types, generic arguments.

Structural, not nominal

Any object with the right shape is a User. You don't need new User(). If a literal has name, age, email with the right types, TypeScript accepts it.

Semicolons or commas

Both work: { name: string; age: number } and { name: string, age: number }. Pick one style and stay consistent. Semicolons are more common in real codebases.

Erased at compile

The interface doesn't appear in the output. It's a compile-time construct — no runtime cost, no instanceof User, no way to log it. It exists only to check your code.

Naming convention: singular, PascalCase. User, not Users or IUser. The "I" prefix (as in IUser) comes from C# and Java and is discouraged in modern TypeScript — the type system handles disambiguation. Reserve plural names for collection types (UserList, User[]) and keep the shape itself singular.

🎛️ Optional and readonly — controlling what's allowed

Two modifiers that carry most of the weight in real data shapes.

Real data isn't always complete, and some fields shouldn't change after creation. TypeScript's two property modifiers — ? for optional and readonly for immutable — handle both, and both are erased at compile time (they check, not enforce).

profile.ts
interface Profile {
readonly id: string; // can't be reassigned
name: string; // required
bio?: string; // optional — string | undefined
age?: number;
}

const p: Profile = {
id: 'u_123',
name: 'Alice'
// bio and age omitted — allowed because they're optional
};

p.name = 'Alicia'; // ✅ allowed — name is mutable
// p.id = 'u_999'; // ❌ Cannot assign to 'id' because it is a read-only property

// Optional fields require narrowing before use:
// p.bio.toUpperCase() // ❌ 'p.bio' is possibly 'undefined'
if (p.bio) {
console.log(p.bio.toUpperCase()); // ✅ narrowed to string
}

// ─── readonly is shallow ───────────────────────
interface Tagged {
readonly tags: string[];
}
const t: Tagged = { tags: ['a', 'b'] };
t.tags.push('c'); // ✅ readonly blocks reassignment, not mutation
// t.tags = ['x']; // ❌ Cannot assign to 'tags' (the reference is readonly)
// All type-level modifiers erased. JS has no concept of readonly.
// Runtime behaves as if you wrote the shape as a plain object.
// readonly and ? only affect what TypeScript allows — the object
// itself is a normal mutable JS object at runtime.

const p = {
id: 'u_123',
name: 'Alice'
};

p.name = 'Alicia';
// p.id = 'u_999'; ← this WOULD work in JS. TS stops it.

if (p.bio) {
console.log(p.bio.toUpperCase());
}

const t = { tags: ['a', 'b'] };
t.tags.push('c');

? means | undefined

bio?: string is the same as bio: string | undefined, but the property can also be omitted from the literal. Accessing it forces a null check first.

readonly is compile-only

It blocks reassignment in TypeScript, not at runtime. The object is still a mutable JS object — another module that receives it can mutate it unless you freeze it.

Readonly is shallow

readonly tags: string[] blocks t.tags = [...] but allows t.tags.push(...). For deep immutability, use readonly string[] and as const.

Trust the compiler

When you see "Cannot assign to 'id' because it is a read-only property," you're catching a real bug. Never work around it with as any — restructure instead.

readonly is not Object.freeze. readonly is a compile-time annotation — it disappears from the output, so any code that receives the object can still mutate it. If you need runtime immutability (e.g. for a state store), use Object.freeze(obj) in JavaScript, or as const with readonly for both levels of protection. These are different tools solving different problems.

🧩 Extend and compose — building on existing shapes

Interfaces can inherit, and both interfaces and types can be combined.

Real data shapes are rarely standalone. An Admin is a User with extra fields. A Request is a Base plus authorization. TypeScript gives interfaces extends and gives both forms an intersection operator (&).

extends.ts
// ─── interface extends ────────────────────────
interface User {
id: string;
name: string;
}

interface Admin extends User {
role: 'admin';
permissions: string[];
}

const a: Admin = {
id: 'u_1',
name: 'Alice',
role: 'admin',
permissions: ['read', 'write']
};

// An Admin is assignable everywhere a User is expected:
function printName(u: User) {
console.log(u.name);
}
printName(a); // ✅ Admin has every property User has

// ─── Multiple extends ─────────────────────────
interface Timestamps {
createdAt: Date;
updatedAt: Date;
}
interface Post extends User, Timestamps {
title: string;
body: string;
}

// ─── Intersection with & ─────────────────────
type Base = { id: string };
type Named = { name: string };
type Both = Base & Named; // { id: string; name: string }

// ─── Composition over inheritance ────────────
// The modern preference: build small pieces and intersect them.
type Entity = { id: string } & Timestamps;
type Article = Entity & { title: string; body: string };
// Every type-level declaration erased.
// Only the runtime values remain:

const a = {
id: 'u_1',
name: 'Alice',
role: 'admin',
permissions: ['read', 'write']
};

function printName(u) {
console.log(u.name);
}
printName(a);

extends for interfaces

Inherits all properties from the parent. Add more, override types (must remain compatible), or extend multiple interfaces with a comma-separated list.

& for types

Intersection creates a new type with all properties from both sides. This is the type-level equivalent of "combine these two shapes" — the modern idiom for composition.

Assignability flows up

An Admin is assignable to a User parameter — it has all the required fields. The reverse is not true: passing a User to Admin fails because role is missing.

Prefer composition

Real codebases lean on & with small types rather than deep interface hierarchies. Small types compose better, refactor cleanly, and read as documentation.

Intersection is not object merge. A & B requires a value to satisfy both A and B — nothing is merged at runtime. If A has id: string and B has id: number, the intersection is { id: never } — an impossible type with zero valid values. This is a feature: it catches shape conflicts at compile time.

🏷️ Type aliases — naming any type, not just objects

Where interfaces stop, type aliases keep going.

Interfaces can only describe object shapes. type aliases can name anything: unions, tuples, function signatures, primitives, and conditional types. This is the biggest capability difference between the two, and it's why modern code uses type more than interface for non-object things.

aliases.ts
// ─── Primitives with a name ───────────────────
type UserID = string;
type Score = number;

// ─── Unions (interface cannot do this) ────────
type ID = string | number;
type Status = 'idle' | 'loading' | 'success' | 'error';

// ─── Function signatures ─────────────────────
type Handler = (event: MouseEvent) => void;
type Comparator = (a: T, b: T) => number;

// ─── Tuples ──────────────────────────────────
type Point = [number, number];
type RGB = [number, number, number];

const p: Point = [10, 20];
// const bad: Point = [10, 20, 30]; // ❌ too many elements

// ─── Object shape (interface & type both work) ─
type User = {
id: ID;
name: string;
status: Status;
};

// ─── Union of literals — the discriminator pattern
type Result =
| { status: 'ok'; data: User }
| { status: 'error'; message: string };
// Only runtime values survive.
// Type aliases — including unions, tuples, and function types — are erased.

const p = [10, 20];
// const bad = [10, 20, 30]; ← valid in JS, TS prevents it

Unions

type ID = string | number — only type can do this. Unions are the foundation of narrowing (next lesson) and the discriminated-union pattern.

Function types

type Handler = (e: Event) => void names a function signature. Use it for parameters and callbacks — it's much cleaner than inlining the signature every time.

Tuples

[number, number] is a tuple of exactly two numbers. Position and length are checked. Use tuples for fixed-length data: coordinates, pairs, tuples from APIs.

Literal unions

'idle' | 'loading' | 'success' restricts to exactly those strings. The compiler catches typos — 'lodiang' fails. This replaces enums in most modern code.

Prefer string literal unions over enums. type Status = 'idle' | 'loading' | 'success' is simpler than enum Status { Idle, Loading, Success }. It has zero runtime output, works with plain strings across API boundaries, and doesn't require importing the enum to compare against it. TypeScript enums have known pitfalls — the literal union is the modern default.

⚖️ interface vs type — the real difference

95% of the time, they're interchangeable. Here's the other 5%.

You'll read conflicting advice about which to use. Most of it is opinion. Here is the factual difference, and the decision rule that follows from it.

declaration-merging.ts
// ─── The one difference: declaration merging ──

// Interfaces with the same name MERGE:
interface Config {
port: number;
}
interface Config {
host: string;
}
// Result: { port: number; host: string }
// Both declarations contribute. This is how libraries extend
// built-in types — e.g. React's JSX namespace, Express's Request.

// Type aliases with the same name ERROR:
type Config2 = { port: number };
// type Config2 = { host: string }; // ❌ Duplicate identifier 'Config2'

// ─── extends vs intersection ──────────────────

interface A { a: string }
interface B extends A { b: number } // clean, intent-revealing

type TA = { a: string };
type TB = TA & { b: number }; // equivalent, slightly noisier

// ─── The decision rule ────────────────────────

// ✅ Use interface when:
// - describing an object shape (User, Post, Props)
// - the shape is public / exported from a library
// - consumers may need to augment it (declaration merging)
// - you're typing React props or Express request/response

// ✅ Use type when:
// - describing a union (Status, ID, Result)
// - describing a tuple or function signature
// - describing a mapped or conditional type
// - the shape is local to one file and won't be extended

// In a codebase, consistency matters more than the choice.
// Pick one style per project and stick with it.

Merge (interface only)

Two interfaces with the same name combine. Libraries rely on this to extend built-in types — e.g. adding a custom field to Request. This is the one case that forces interface.

Unions (type only)

type X = A | B — interface cannot express a union. If your type is "one of these," it's a type alias, full stop.

Error messages

Interfaces often produce slightly cleaner error messages because the compiler can reference them by name. In large codebases this is a real (if minor) quality-of-life difference.

Performance

Interfaces are typically slightly faster for the compiler to check than deeply nested intersections. In practice this rarely matters — but in large monorepos, prefer interface for hot shapes.

Don't mix styles randomly. If half your code uses type User = {…} and the other half uses interface User {…}, readers can't tell whether the choice was intentional. Pick one — most teams pick interface for object shapes and type for everything else — and apply it consistently. The compiler will accept both; your team won't.

🏆 Challenge

Three tasks. Each exercises a different interface/type feature.

Task 1 — Model a blog post

Design a type that represents a blog post with:

1. A required id (readonly, string)
2. A required title (string)
3. A required body (string)
4. An optional subtitle (string)
5. An optional tags (readonly string[])
6. A required status that is one of 'draft' | 'published' | 'archived'

Start with an interface. Then write a second type PublishedPost that refines status to 'published'. You'll find that interface can't narrow the property cleanly — use an intersection with a type. This is the kind of decision you'll make dozens of times a day in real TS.

Task 2 — Merge and extend

Practice the two composition tools:

1. Define an interface Entity with readonly id: string
2. Define an interface Timestamps with createdAt: Date; updatedAt: Date
3. Define a User interface that extends both
4. Define a Comment type that intersects both plus { text: string }
5. Declare Entity a second time with an extra field and confirm it merges (only works for interface!)

The point: extends and & are the two composition primitives. They produce the same shapes — the choice is about which reads better and which the surrounding code already uses.

Task 3 — Index signature for a dictionary

Build a typed translation dictionary:

1. type Locale = 'en' | 'fr' | 'es'
2. An interface Messages with an index signature: [key: string]: string
3. A Record holding one object per language
4. A function t(locale: Locale, key: string): string that returns the translation, falling back to English if the key is missing in that locale
5. Test it with a key that exists in all locales, and one missing from fr

This is a tiny version of what every internationalization library does. The index signature is what makes the dictionary dynamic — TypeScript still checks that every value is a string, but the keys are open-ended.

⚠️ Common mistakes

The interface and type bugs that catch every beginner.

1. Using interface for everything

interface Status = 'idle' | 'loading' — doesn't compile. Interfaces can only describe object shapes. Unions, tuples, and primitives need type.

2. Using type for everything

Technically valid, but exported object shapes used by consumers lose the option to merge and may produce slightly worse error messages. Convention: interface for object shapes, type for the rest.

3. Thinking readonly is runtime

readonly is erased. Another module that receives your object can mutate it. Fix: use Object.freeze for runtime immutability, or as const for literal freezing.

4. Forgetting optional fields are nullable

bio?: string means string | undefined. Calling user.bio.toUpperCase() fails. Fix: narrow with if (user.bio) or use optional chaining user.bio?.toUpperCase().

;
5. Using index signature without types

[key: string]: any defeats the point. The index signature's value type is the check — [key: string]: number means all values must be numbers. Never fall back to any here.

6. Relying on declaration merging without knowing

If you name two interfaces Config in different files, they merge — sometimes silently, sometimes wrongly. Fix: avoid reusing interface names across files unless you intend to merge (e.g. module augmentation).

7. Intersection where you meant union

type Result = { ok: true } & { error: string } requires both ok and error — an impossible combination. You wanted |. Fix: intersect to combine, union to choose.

8. Assuming interfaces exist at runtime

instanceof User fails — User was erased. Fix: use a class if you need a runtime value, or a discriminated union tag (kind: 'user') if you need discrimination.

❓ Frequently asked questions

What is an interface in TypeScript?

An interface describes the shape of an object — what properties it has and what types those properties are. interface User { name: string; age: number; } says any value typed as User must have a name that is a string and an age that is a number. Interfaces are compile-time only — they don't exist in the output JavaScript.

What is a type alias?

A type alias gives a name to any type, not just object shapes. type ID = string | number creates a name for a union. type Callback = (x: number) => void names a function type. type User = { name: string } describes an object. Type aliases are more flexible than interfaces.

What is the difference between interface and type?

Both describe object shapes and for 95% of cases are interchangeable. The real differences: (1) interface supports declaration merging — you can define it twice and the definitions combine; type cannot. (2) interface can be extended with extends; type uses intersections (&). (3) type can describe unions, tuples, primitives, and conditional types; interface cannot.

What are optional properties?

A question mark after a property name makes it optional. interface User { name: string; age?: number } means age can be a number or it can be missing entirely — TypeScript treats it as number | undefined. Accessing user.age gives number | undefined, so you must narrow before using it as a number.

What does readonly do?

readonly prevents reassignment after the value is created. readonly id: string means you can read obj.id but not assign to it — obj.id = 'new' is a compile error. This is compile-time only: it doesn't freeze the object at runtime. For deep immutability, use as const or Object.freeze.

What is an index signature?

An index signature describes objects with dynamic keys. interface Dict { [key: string]: number } says any string key maps to a number — so dict['anything'] is a number. Useful for dictionaries and maps. Combine with known keys carefully — all keys must match the index signature type.

When should I use interface vs type?

Use interface when you're describing an object shape that might be extended or that a library might augment (e.g. React's Props, Express's Request). Use type when you're describing a union, a tuple, a function signature, a mapped type, or anything non-object. If you're unsure: interface for public/exported object shapes, type for everything else. Consistency within a project matters more than the choice itself.

Do interfaces exist at runtime?

No. Interfaces are erased by the TypeScript compiler. You can't instanceof a User interface, you can't check typeof User, you can't log User — it doesn't exist in the output JavaScript. If you need a runtime value, use a class.

🔗 Continue your TypeScript path

🌐 External resources

🔧 Related problem solvers

← Back to TypeScript track
You're in the TypeScript track · Lesson 2