Episode 1 — Fundamentals / 1.25 — TypeScript Essentials
1.25.e — Interfaces vs Type Aliases
In one sentence: Interfaces describe the shape of objects and can be extended or merged, while type aliases can represent any type — unions, primitives, tuples, and complex compositions — making each suited to different situations.
Navigation: ← 1.25 Overview · 1.25.f — Union and Intersection Types →
1. interface — describing object shapes
An interface declares the structure an object must have:
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
// An object must match ALL properties:
const alice: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
isActive: true,
};
// Missing property → error:
// const bob: User = { id: 2, name: "Bob" };
// Error: Type '...' is missing the following properties: email, isActive
Interfaces with methods
interface Calculator {
add(a: number, b: number): number;
subtract(a: number, b: number): number;
// Alternative syntax:
multiply: (a: number, b: number) => number;
}
const calc: Calculator = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
};
2. type — creating type aliases
A type alias gives a name to any type expression:
// Object type (looks similar to interface)
type User = {
id: number;
name: string;
email: string;
isActive: boolean;
};
// But type aliases can also represent:
type ID = string | number; // Union
type Callback = (data: string) => void; // Function
type Pair = [string, number]; // Tuple
type Nullable<T> = T | null; // Generic alias
type Status = "pending" | "active" | "inactive"; // Literal union
type Coordinate = { x: number; y: number }; // Object
3. Interface extends — inheritance
Interfaces can extend other interfaces to inherit their properties:
interface Person {
name: string;
age: number;
}
interface Employee extends Person {
employeeId: string;
department: string;
}
// Employee must have ALL properties from Person + its own:
const emp: Employee = {
name: "Alice",
age: 30,
employeeId: "E001",
department: "Engineering",
};
// Multiple inheritance — extend several interfaces:
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface AuditedEmployee extends Employee, Timestamped {
lastLogin: Date;
}
4. Type intersection — composing types
Type aliases use & (intersection) to combine types:
type Person = {
name: string;
age: number;
};
type ContactInfo = {
email: string;
phone: string;
};
// Intersection: must satisfy BOTH types
type Employee = Person & ContactInfo & {
employeeId: string;
department: string;
};
const emp: Employee = {
name: "Alice",
age: 30,
email: "alice@example.com",
phone: "555-0100",
employeeId: "E001",
department: "Engineering",
};
extends (interface) vs & (type) — comparison:
| Feature | interface extends | type & (intersection) |
|---|---|---|
| Syntax | interface B extends A | type B = A & { ... } |
| Multiple | extends A, B, C | A & B & C |
| Conflict handling | Compile error on incompatible properties | Creates never for conflicting properties |
| Performance | Slightly better (cached by compiler) | Computed on each use |
5. Declaration merging — interfaces can be re-opened
Interfaces with the same name in the same scope are automatically merged:
interface User {
id: number;
name: string;
}
// Later in the same scope (or another file):
interface User {
email: string;
}
// Now User has ALL properties:
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com", // Required — from second declaration
};
When is this useful?
- Extending third-party types — add properties to library interfaces without modifying their source
- Module augmentation — extend Express
Request, Window, etc.
// Example: adding a custom property to Express Request
declare module "express" {
interface Request {
userId?: string;
}
}
Type aliases CANNOT be re-opened:
type User = { id: number; name: string };
// type User = { email: string }; // Error: Duplicate identifier 'User'
6. Optional properties
Use ? to mark properties that may or may not exist:
interface Config {
host: string;
port: number;
ssl?: boolean; // Optional — may be undefined
timeout?: number; // Optional
}
// All valid:
const config1: Config = { host: "localhost", port: 3000 };
const config2: Config = { host: "localhost", port: 3000, ssl: true };
const config3: Config = { host: "localhost", port: 3000, ssl: true, timeout: 5000 };
// When accessing optional properties, TypeScript knows they might be undefined:
function getTimeout(config: Config): number {
// config.timeout is 'number | undefined'
return config.timeout ?? 3000; // Default to 3000 if undefined
}
7. Readonly properties
Use readonly to prevent reassignment after creation:
interface Point {
readonly x: number;
readonly y: number;
}
const origin: Point = { x: 0, y: 0 };
// origin.x = 5; // Error: Cannot assign to 'x' because it is a read-only property
// Readonly is shallow — nested objects can still be mutated:
interface Config {
readonly settings: {
theme: string;
};
}
const config: Config = { settings: { theme: "dark" } };
// config.settings = { theme: "light" }; // Error — can't reassign 'settings'
config.settings.theme = "light"; // OK — inner object is NOT readonly
// For deep readonly, use Readonly<T> utility type or custom deep readonly
8. Index signatures
When you do not know all property names in advance but know their types:
interface StringMap {
[key: string]: string;
}
const translations: StringMap = {
hello: "hola",
goodbye: "adios",
thanks: "gracias",
// Any string key → string value is valid
};
// Numeric index signature (for array-like objects)
interface NumberArray {
[index: number]: string;
}
const arr: NumberArray = ["a", "b", "c"];
// Combining fixed and index signatures
interface UserMap {
[userId: string]: User;
// All values must be User
}
// Mixed: known + dynamic properties
interface Env {
NODE_ENV: "development" | "production" | "test";
PORT: string;
[key: string]: string; // Allow any additional env vars
}
9. implements — interfaces with classes
Classes can implement interfaces, ensuring they provide all required members:
interface Printable {
print(): string;
}
interface Loggable {
log(message: string): void;
}
class Report implements Printable, Loggable {
constructor(private title: string) {}
print(): string {
return `Report: ${this.title}`;
}
log(message: string): void {
console.log(`[Report] ${message}`);
}
}
// The interface defines the CONTRACT — the class provides the IMPLEMENTATION
// Multiple classes can implement the same interface differently:
class Invoice implements Printable {
constructor(private amount: number) {}
print(): string {
return `Invoice: $${this.amount.toFixed(2)}`;
}
}
Note: type aliases cannot be used with implements (only interface).
10. When to use interface vs type
Use interface when:
- Defining object shapes (the primary use case)
- You want declaration merging (extending third-party types)
- Defining class contracts (
implements) - Building public APIs where consumers may extend your types
Use type when:
- Creating union types:
type ID = string | number - Creating tuple types:
type Pair = [string, number] - Creating function types:
type Handler = (e: Event) => void - Creating mapped or conditional types (advanced)
- Aliasing primitives:
type Seconds = number
Quick reference table
| Capability | interface | type |
|---|---|---|
| Object shape | Yes | Yes |
| Extend/inherit | extends | & intersection |
| Declaration merging | Yes | No |
implements (classes) | Yes | No |
| Union types | No | Yes |
| Tuple types | No | Yes |
| Primitive aliases | No | Yes |
| Function types | Yes (verbose) | Yes (clean) |
| Mapped types | No | Yes |
| Computed properties | No | Yes |
Practical guideline
Default to
interfacefor object shapes. Usetypefor everything else.
This is not a strict rule — many teams use type for everything and that works fine too. Consistency within a project matters more than which you choose.
11. Real-world examples
API response types
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
interface PaginatedResponse<T> extends ApiResponse<T[]> {
page: number;
totalPages: number;
totalItems: number;
}
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user" | "guest";
}
// Usage:
async function fetchUsers(): Promise<PaginatedResponse<User>> {
const response = await fetch("/api/users");
return response.json();
}
const result = await fetchUsers();
result.data[0].name; // string — fully typed!
result.totalPages; // number
React component props
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
size?: "small" | "medium" | "large";
icon?: React.ReactNode;
}
// Extending base HTML attributes:
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
Form state with types
type FormStatus = "idle" | "submitting" | "success" | "error";
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
status: FormStatus;
isDirty: boolean;
}
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
// Usage:
const loginState: FormState<LoginForm> = {
values: { email: "", password: "", rememberMe: false },
errors: {},
status: "idle",
isDirty: false,
};
Key takeaways
- Interfaces describe object shapes and support
extends, declaration merging, andimplements. - Type aliases can represent any type — unions, tuples, primitives, functions, and complex compositions.
- Interfaces can be re-opened (declaration merging); type aliases cannot.
- Use
?for optional properties andreadonlyfor immutable properties. - Index signatures (
[key: string]: Type) handle objects with dynamic keys. - Default to
interfacefor object shapes,typefor everything else — but consistency matters more.
Explain-It Challenge
Explain without notes:
- What can a
typealias do that aninterfacecannot? - What is declaration merging, and when is it useful?
- What does
readonlydo, and why is it considered "shallow"?
Navigation: ← 1.25 Overview · 1.25.f — Union and Intersection Types →