๐๏ธ Interfaces and Type Aliases
Welcome to the world of complex types! If basic types are LEGO bricks, then interfaces and type aliases are the instruction manuals that show you how to build magnificent structures. In this lesson, we'll learn how to describe complex objects, create reusable type definitions, and make our code more maintainable and self-documenting. ๐จ
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Create and use interfaces to define object shapes
- Understand the difference between interfaces and type aliases
- Use optional and readonly properties effectively
- Extend and compose interfaces for code reuse
- Create index signatures for dynamic properties
- Choose the right tool (interface vs type alias) for each scenario
Estimated Time: 60-75 minutes
Project: Build a type-safe user management system with complex data structures
๐ In This Lesson
What Are Interfaces?
Imagine you're designing a building. Before construction begins, you create a blueprint that shows exactly what the building should look like - how many floors, where the doors go, the size of each room. An interface is like a blueprint for your data structures. It defines the exact shape an object should have. ๐
๐ Definition
Interface: A way to define the structure of an object in TypeScript. It describes what properties an object should have, what types those properties should be, and what methods the object should support. Think of it as a contract that objects must fulfill.
Why Interfaces Matter
Without interfaces, you'd have to remember what properties each object has. With hundreds of objects in a real application, that's impossible! Interfaces solve this by:
- ๐ฏ Documenting structure: Anyone can see what an object should contain
- ๐ก๏ธ Enforcing consistency: All objects of the same type have the same shape
- ๐ Enabling autocomplete: Your editor knows what properties exist
- ๐ Catching errors early: TypeScript warns you if you miss a property
- ๐ Making refactoring safe: Change the interface, and TypeScript shows you what breaks
A Real-World Analogy
Think of interfaces like a job description. When you hire a "Software Developer," you expect certain skills - they should know how to code, debug, and work with teams. The job title (interface name) tells you what to expect. If someone applies for "Software Developer" but can't code, that's a problem! Similarly, if an object claims to implement an interface but is missing properties, TypeScript catches it. ๐ผ
๐ก Key Insight: Interfaces don't create any JavaScript code. They exist only during development to help TypeScript check your code. Once compiled, they disappear completely - they're just documentation and validation!
Creating Your First Interface
Let's dive right in and create some interfaces! We'll start simple and build up to more complex examples. ๐
Basic Interface Syntax
Here's the fundamental structure of an interface:
// Define an interface
interface User {
id: number;
name: string;
email: string;
age: number;
}
// Create an object that matches the interface
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
age: 28
};
// TypeScript is happy! โ
console.log(user.name); // "Alice"
Let's break this down:
interface User- The keywordinterfacefollowed by the name (PascalCase)- Inside curly braces: list each property with its type
- Each property ends with a semicolon (or comma - both work!)
- When creating an object, you must include ALL properties with correct types
What Happens When You Violate the Interface?
TypeScript enforces the interface strictly. Let's see what happens when we break the rules:
interface User {
id: number;
name: string;
email: string;
age: number;
}
// โ Missing properties
const user1: User = {
id: 1,
name: "Bob"
// Error! Property 'email' is missing
// Error! Property 'age' is missing
};
// โ Wrong type
const user2: User = {
id: "123", // Error! Type 'string' is not assignable to type 'number'
name: "Charlie",
email: "charlie@example.com",
age: 30
};
// โ Extra properties
const user3: User = {
id: 1,
name: "Diana",
email: "diana@example.com",
age: 25,
country: "USA" // Error! Property 'country' does not exist on type 'User'
};
// โ
This is correct!
const user4: User = {
id: 1,
name: "Eve",
email: "eve@example.com",
age: 32
};
โ ๏ธ Strict Property Checking
TypeScript is very strict about object literals. You can't have extra properties, missing properties, or wrong types. This strictness prevents bugs!
Interfaces with Methods
Interfaces can describe methods (functions) too! This is perfect for describing objects that have behavior:
interface Calculator {
// Properties
brand: string;
model: string;
// Methods
add(a: number, b: number): number;
subtract(a: number, b: number): number;
clear(): void;
}
// Implement the interface
const myCalculator: Calculator = {
brand: "Casio",
model: "FX-991",
add(a: number, b: number): number {
return a + b;
},
subtract(a: number, b: number): number {
return a - b;
},
clear(): void {
console.log("Calculator cleared");
}
};
// Use it
console.log(myCalculator.add(5, 3)); // 8
console.log(myCalculator.subtract(5, 3)); // 2
myCalculator.clear(); // "Calculator cleared"
Nested Interfaces
Real-world objects are often complex with nested structures. Interfaces handle this beautifully:
// Define a nested interface
interface Address {
street: string;
city: string;
state: string;
zipCode: string;
}
interface User {
id: number;
name: string;
email: string;
address: Address; // Nested interface!
}
// Create an object with nested structure
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
address: {
street: "123 Main St",
city: "Springfield",
state: "IL",
zipCode: "62701"
}
};
// Access nested properties
console.log(user.address.city); // "Springfield"
โ Pro Tip: Compose Small Interfaces
Instead of one giant interface, break it into smaller, reusable pieces. This makes your code more maintainable and easier to understand!
// Good: Small, focused interfaces
interface ContactInfo {
email: string;
phone: string;
}
interface PersonalInfo {
firstName: string;
lastName: string;
dateOfBirth: string;
}
interface User {
id: number;
contact: ContactInfo;
personal: PersonalInfo;
}
Interfaces for Function Types
You can even use interfaces to describe function signatures:
// Interface for a function
interface MathOperation {
(a: number, b: number): number;
}
// Functions that match the interface
const add: MathOperation = (a, b) => a + b;
const multiply: MathOperation = (a, b) => a * b;
console.log(add(5, 3)); // 8
console.log(multiply(5, 3)); // 15
// This won't work - wrong signature
const concat: MathOperation = (a, b) => `${a}${b}`; // โ Error! Returns string, not number
Real-World Example: E-Commerce Product
Let's model a realistic e-commerce product with all we've learned:
interface Price {
amount: number;
currency: string;
}
interface Dimensions {
length: number;
width: number;
height: number;
unit: "cm" | "in"; // Literal type!
}
interface Review {
userId: number;
rating: number;
comment: string;
date: string;
}
interface Product {
id: string;
name: string;
description: string;
price: Price;
inStock: boolean;
dimensions: Dimensions;
category: string;
tags: string[];
reviews: Review[];
// Method
getAverageRating(): number;
}
// Implement the product
const laptop: Product = {
id: "LAPTOP-001",
name: "ThinkPad X1 Carbon",
description: "Lightweight business laptop",
price: {
amount: 1299.99,
currency: "USD"
},
inStock: true,
dimensions: {
length: 32.3,
width: 21.7,
height: 1.49,
unit: "cm"
},
category: "Electronics",
tags: ["laptop", "business", "portable"],
reviews: [
{
userId: 101,
rating: 5,
comment: "Excellent laptop!",
date: "2024-01-15"
},
{
userId: 102,
rating: 4,
comment: "Great, but expensive",
date: "2024-01-20"
}
],
getAverageRating(): number {
if (this.reviews.length === 0) return 0;
const sum = this.reviews.reduce((acc, review) => acc + review.rating, 0);
return sum / this.reviews.length;
}
};
console.log(laptop.getAverageRating()); // 4.5
๐ฏ Notice the Power!
- โ Every product has the same structure - consistency guaranteed
- โ Autocomplete works perfectly - your editor suggests properties
- โ
Typos are caught immediately - can't write
laptop.prise - โ Refactoring is safe - change the interface and find all affected code
- โ Documentation is built-in - the interface IS the documentation!
Optional and Readonly Properties
Not all properties are created equal! Some are required, some are optional, and some should never change after creation. TypeScript gives us tools to express these nuances. Let's explore! ๐ง
Optional Properties (?)
Sometimes a property might not always be present. Use the ? symbol to mark it as optional:
interface User {
id: number;
name: string;
email: string;
age?: number; // Optional - might not exist
phone?: string; // Optional
website?: string; // Optional
}
// All of these are valid!
const user1: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
age: 28,
phone: "555-1234"
// website is omitted - that's OK!
};
const user2: User = {
id: 2,
name: "Bob",
email: "bob@example.com"
// age, phone, and website all omitted - all OK!
};
const user3: User = {
id: 3,
name: "Charlie",
email: "charlie@example.com",
age: 35,
phone: "555-5678",
website: "https://charlie.dev"
// All properties present - also OK!
};
Working with Optional Properties
When you access an optional property, TypeScript knows it might be undefined:
interface User {
name: string;
age?: number;
}
function greetUser(user: User): string {
// age might be undefined, so be careful!
if (user.age) {
return `Hello ${user.name}, you are ${user.age} years old`;
} else {
return `Hello ${user.name}`;
}
// Or use optional chaining
return `Hello ${user.name}${user.age ? `, you are ${user.age}` : ''}`;
}
const user1: User = { name: "Alice", age: 28 };
const user2: User = { name: "Bob" };
console.log(greetUser(user1)); // "Hello Alice, you are 28 years old"
console.log(greetUser(user2)); // "Hello Bob"
๐ก Best Practice: Use optional properties for data that's truly optional - like a middle name, phone number, or profile picture. Don't overuse them - if a property is always needed, make it required!
Readonly Properties (readonly)
Some properties should never change after they're set - like an ID or creation timestamp. Use readonly to enforce this:
interface User {
readonly id: number; // Can't be changed after creation
readonly createdAt: string; // Can't be changed after creation
name: string; // Can be changed
email: string; // Can be changed
}
const user: User = {
id: 1,
createdAt: "2024-01-15",
name: "Alice",
email: "alice@example.com"
};
// These are allowed โ
user.name = "Alice Smith";
user.email = "alice.smith@example.com";
// These cause errors โ
user.id = 2; // Error! Cannot assign to 'id' because it is a read-only property
user.createdAt = "2024-01-20"; // Error! Cannot assign to 'createdAt'
โ When to Use readonly
- IDs: Identifiers should never change
- Timestamps: Creation/modification dates
- Configuration: Settings that shouldn't be modified
- Constants: Fixed values like API keys (from environment)
Combining Optional and Readonly
You can use both modifiers together:
interface BlogPost {
readonly id: string; // Required, can't change
readonly authorId: number; // Required, can't change
title: string; // Required, can change
content: string; // Required, can change
publishedAt?: readonly string; // Optional, but if set, can't change
tags?: string[]; // Optional, can change
}
const post: BlogPost = {
id: "post-123",
authorId: 42,
title: "Learning TypeScript",
content: "TypeScript is awesome!",
publishedAt: "2024-01-15"
};
// Allowed โ
post.title = "Mastering TypeScript";
post.tags = ["typescript", "tutorial"];
// Not allowed โ
post.id = "post-456"; // Error! readonly
post.authorId = 99; // Error! readonly
post.publishedAt = "2024-01-20"; // Error! readonly
Real-World Example: Immutable Configuration
Perfect for application config that shouldn't change at runtime:
interface AppConfig {
readonly apiUrl: string;
readonly apiKey: string;
readonly environment: "development" | "staging" | "production";
readonly maxRetries: number;
theme?: "light" | "dark"; // Optional, user can change
language?: string; // Optional, user can change
}
const config: AppConfig = {
apiUrl: "https://api.example.com",
apiKey: "secret-key-12345",
environment: "production",
maxRetries: 3,
theme: "dark"
};
// User preferences can change โ
config.theme = "light";
config.language = "es";
// But core config is locked โ
config.apiUrl = "https://hacker.com"; // Error! Can't change API URL
config.environment = "development"; // Error! Can't change environment
Type Aliases Explained
So far we've focused on interfaces, but TypeScript has another powerful tool: type aliases. If interfaces are like blueprints, type aliases are like custom labels you can stick on anything. They're incredibly flexible! ๐ท๏ธ
What is a Type Alias?
A type alias creates a new name for any type - primitives, unions, tuples, objects, and more. Think of it as creating a shortcut or nickname for a type:
๐ Definition
Type Alias: A way to give a name to any type. Unlike interfaces which only describe object shapes, type aliases can represent any type at all - primitives, unions, tuples, intersections, and objects.
Basic Type Alias Syntax
Use the type keyword to create an alias:
// Alias for a primitive type
type ID = string | number;
// Alias for a union type
type Status = "pending" | "approved" | "rejected";
// Alias for an object type
type User = {
id: ID;
name: string;
status: Status;
};
// Using the aliases
let userId: ID = 123; // โ
number works
userId = "user-abc"; // โ
string works
let orderStatus: Status = "pending"; // โ
OK
// orderStatus = "completed"; // โ Error! Not in the union
const user: User = {
id: 1,
name: "Alice",
status: "approved"
};
Type Aliases for Primitives and Unions
Type aliases shine when working with unions and complex combinations:
// Simple primitive alias
type Age = number;
type Name = string;
// Union types (very common!)
type ID = string | number;
type Result = "success" | "failure" | "pending";
type Padding = number | string; // Could be 10 or "10px"
// Complex unions
type Response =
| { success: true; data: any }
| { success: false; error: string };
// Using them
function handleResponse(response: Response) {
if (response.success) {
console.log(response.data); // TypeScript knows data exists here
} else {
console.log(response.error); // TypeScript knows error exists here
}
}
Type Aliases for Tuples
Type aliases are perfect for naming tuple types:
// Without alias - hard to read
function getCoordinates(): [number, number] {
return [10, 20];
}
// With alias - much clearer!
type Point = [number, number];
function getCoordinates(): Point {
return [10, 20];
}
// More complex tuples
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
const red: RGB = [255, 0, 0];
const semiTransparentBlue: RGBA = [0, 0, 255, 0.5];
// Named tuple members (TypeScript 4.0+)
type Range = [start: number, end: number];
type HTTPResponse = [status: number, body: string];
const range: Range = [0, 100];
const response: HTTPResponse = [200, "OK"];
Type Aliases for Object Types
Type aliases can describe object shapes just like interfaces:
// Object type alias
type User = {
id: number;
name: string;
email: string;
isActive: boolean;
};
// With optional properties
type Product = {
id: string;
name: string;
price: number;
description?: string; // Optional
imageUrl?: string; // Optional
};
// With readonly properties
type Config = {
readonly apiUrl: string;
readonly timeout: number;
retries: number; // Can be changed
};
// With methods
type Calculator = {
add: (a: number, b: number) => number;
subtract: (a: number, b: number) => number;
};
const calc: Calculator = {
add: (a, b) => a + b,
subtract: (a, b) => a - b
};
Type Aliases for Functions
Type aliases make function signatures reusable and readable:
// Function type alias
type MathOperation = (a: number, b: number) => number;
// Use it multiple times
const add: MathOperation = (a, b) => a + b;
const multiply: MathOperation = (a, b) => a * b;
const divide: MathOperation = (a, b) => a / b;
// More complex function types
type Validator = (value: string) => boolean;
type AsyncFetcher = (url: string) => Promise;
type EventHandler = (event: Event) => void;
// Callback function type
type ComparisonFunction = (a: T, b: T) => number;
function sort(items: T[], compare: ComparisonFunction): T[] {
return items.sort(compare);
}
const numbers = [3, 1, 4, 1, 5, 9];
const sorted = sort(numbers, (a, b) => a - b);
๐ฏ Real-World Example: API Response Types
// Define all possible response types
type SuccessResponse = {
status: "success";
data: T;
timestamp: string;
};
type ErrorResponse = {
status: "error";
message: string;
code: number;
};
type LoadingResponse = {
status: "loading";
};
// Union of all response types
type ApiResponse = SuccessResponse | ErrorResponse | LoadingResponse;
// Use with different data types
type UserResponse = ApiResponse;
type ProductResponse = ApiResponse;
function handleUserResponse(response: UserResponse) {
switch (response.status) {
case "success":
console.log(response.data.name); // TypeScript knows data exists
break;
case "error":
console.log(response.message); // TypeScript knows message exists
break;
case "loading":
console.log("Loading..."); // No additional properties
break;
}
}
Intersection Types with Type Aliases
Type aliases can combine multiple types using & (intersection):
// Combine types with intersection
type Timestamped = {
createdAt: string;
updatedAt: string;
};
type WithId = {
id: number;
};
type User = {
name: string;
email: string;
};
// Combine them all!
type UserRecord = User & WithId & Timestamped;
// UserRecord now has ALL properties:
const user: UserRecord = {
id: 1,
name: "Alice",
email: "alice@example.com",
createdAt: "2024-01-15",
updatedAt: "2024-01-15"
};
๐ก Pro Tip: Intersection types are like merging multiple objects into one. All properties from all types must be present. This is super useful for mixing in common properties like timestamps, IDs, or metadata!
Mapped Types with Type Aliases
Type aliases enable powerful transformations (we'll explore this more later, but here's a taste):
// Make all properties optional
type Partial = {
[P in keyof T]?: T[P];
};
// Make all properties readonly
type Readonly = {
readonly [P in keyof T]: T[P];
};
type User = {
id: number;
name: string;
email: string;
};
type PartialUser = Partial; // All properties optional
type ReadonlyUser = Readonly; // All properties readonly
const partialUser: PartialUser = {
name: "Alice" // Only some properties - OK!
};
const readonlyUser: ReadonlyUser = {
id: 1,
name: "Bob",
email: "bob@example.com"
};
// readonlyUser.name = "Charlie"; // โ Error! All properties are readonly
Interface vs Type Alias: Which to Choose?
The million-dollar question: should you use interface or type? The good news is they're very similar and often interchangeable. But there are key differences that matter. Let's explore! ๐ค
The Similarities
First, let's see what they have in common. Both can describe objects:
// Interface
interface UserInterface {
id: number;
name: string;
email: string;
}
// Type Alias
type UserType = {
id: number;
name: string;
email: string;
};
// Both work the same way!
const user1: UserInterface = {
id: 1,
name: "Alice",
email: "alice@example.com"
};
const user2: UserType = {
id: 2,
name: "Bob",
email: "bob@example.com"
};
Key Differences
1. Declaration Merging (Interface Only)
Interfaces can be declared multiple times and TypeScript merges them. Type aliases cannot:
// โ
Interface declaration merging works
interface User {
id: number;
name: string;
}
interface User {
email: string; // Merged with previous declaration
}
// User now has: id, name, AND email
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com"
};
// โ Type aliases CANNOT be merged
type Person = {
id: number;
name: string;
};
type Person = { // Error! Duplicate identifier 'Person'
email: string;
};
โ ๏ธ When Declaration Merging Matters
Declaration merging is useful for extending third-party types or building libraries. For example, extending the global Window object:
// Extend the built-in Window interface
interface Window {
myCustomProperty: string;
}
// Now you can use it
window.myCustomProperty = "hello";
2. Extending vs Intersections
Interfaces use extends, type aliases use & (intersections):
// Interface extending
interface Animal {
name: string;
age: number;
}
interface Dog extends Animal {
breed: string;
bark(): void;
}
const dog: Dog = {
name: "Buddy",
age: 3,
breed: "Golden Retriever",
bark() {
console.log("Woof!");
}
};
// Type alias intersection
type Animal2 = {
name: string;
age: number;
};
type Dog2 = Animal2 & {
breed: string;
bark(): void;
};
const dog2: Dog2 = {
name: "Max",
age: 5,
breed: "Labrador",
bark() {
console.log("Woof!");
}
};
// Both achieve the same result!
3. Union Types (Type Alias Only)
Type aliases can represent unions, interfaces cannot:
// โ
Type alias can be a union
type Status = "pending" | "approved" | "rejected";
type ID = string | number;
type Result = Success | Error;
// โ Interface cannot be a union
interface Status = "pending" | "approved" | "rejected"; // Syntax error!
// Interfaces can only describe object shapes
interface Success {
status: "success";
data: any;
}
interface Error {
status: "error";
message: string;
}
// Use type alias to create union
type Result = Success | Error;
4. Tuple Types (Type Alias Only)
Tuples are best expressed with type aliases:
// โ
Type alias for tuple
type Point = [number, number];
type RGB = [number, number, number];
// โ Interface for tuple (awkward and not recommended)
interface PointInterface {
0: number;
1: number;
length: 2;
}
// Type alias is much cleaner for tuples!
5. Primitive Types (Type Alias Only)
Type aliases can name any type, interfaces only describe objects:
// โ
Type alias can represent primitives
type Name = string;
type Age = number;
type IsActive = boolean;
// โ Interface cannot represent primitives
interface Name = string; // Syntax error!
The Decision Matrix
| Feature | Interface | Type Alias |
|---|---|---|
| Describe object shapes | โ Yes | โ Yes |
| Declaration merging | โ Yes | โ No |
| Extend/inherit | โ extends keyword | โ & intersection |
| Union types | โ No | โ Yes |
| Tuple types | โ ๏ธ Awkward | โ Perfect |
| Primitive types | โ No | โ Yes |
| Mapped types | โ No | โ Yes |
| Performance | โก Slightly faster compile | โก Very similar |
When to Use Which?
โ Use Interface When:
- Defining object shapes (the most common case)
- You might need declaration merging (libraries, extending third-party types)
- Creating public APIs that others might extend
- You want slightly better error messages (interfaces show the name, types show the full definition)
// Good use of interface
interface User {
id: number;
name: string;
email: string;
}
interface Admin extends User {
permissions: string[];
}
โ Use Type Alias When:
- Creating union types
- Defining tuple types
- Creating aliases for primitives
- Using intersection types extensively
- Creating complex mapped or conditional types
// Good use of type alias
type Status = "pending" | "approved" | "rejected";
type Point = [number, number];
type ID = string | number;
type Result = Success | Error;
The Practical Guideline
Here's a simple rule of thumb that works for most situations:
๐ฏ The Golden Rule
Default to interfaces for object shapes. Use type aliases for unions, tuples, and everything else.
If you're describing the structure of an object or class, use interface. For anything else (unions, intersections, tuples, utility types), use type.
Mixed Example: Best of Both
In real projects, you'll use both! Here's a realistic example:
// Union types - use type alias
type UserRole = "admin" | "editor" | "viewer";
type Status = "active" | "inactive" | "suspended";
// Object shapes - use interface
interface User {
id: number;
name: string;
email: string;
role: UserRole; // Using type alias
status: Status; // Using type alias
}
interface Admin extends User {
permissions: string[];
canDelete: boolean;
}
// Complex response - use type alias
type ApiResponse =
| { success: true; data: T }
| { success: false; error: string };
// Function types - use type alias
type UserValidator = (user: User) => boolean;
type AsyncFetcher = (id: number) => Promise>;
// Use them together!
const validateUser: UserValidator = (user) => {
return user.status === "active" && user.email.includes("@");
};
const fetchUser: AsyncFetcher = async (id) => {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { success: true, data };
} catch (error) {
return { success: false, error: "Failed to fetch user" };
}
};
๐ก Remember: Both interfaces and type aliases are equally valid. The TypeScript team doesn't recommend one over the other for all cases. Use what makes your code clearest and most maintainable!
Extending Interfaces
One of the most powerful features of interfaces is the ability to extend them - building new interfaces on top of existing ones. Think of it like inheritance in object-oriented programming, but for types! ๐๏ธ
Basic Extension
Use the extends keyword to create an interface that includes all properties from another interface plus additional ones:
// Base interface
interface Person {
name: string;
age: number;
}
// Extended interface - has everything from Person, plus more
interface Employee extends Person {
employeeId: number;
department: string;
salary: number;
}
// Employee must have ALL properties: name, age, employeeId, department, salary
const employee: Employee = {
name: "Alice",
age: 30,
employeeId: 12345,
department: "Engineering",
salary: 100000
};
console.log(employee.name); // From Person
console.log(employee.employeeId); // From Employee
Extending Multiple Interfaces
You can extend multiple interfaces at once - combining properties from all of them:
interface Identifiable {
id: number;
}
interface Timestamped {
createdAt: string;
updatedAt: string;
}
interface Taggable {
tags: string[];
}
// BlogPost combines all three!
interface BlogPost extends Identifiable, Timestamped, Taggable {
title: string;
content: string;
authorId: number;
}
const post: BlogPost = {
// From Identifiable
id: 1,
// From Timestamped
createdAt: "2024-01-15",
updatedAt: "2024-01-20",
// From Taggable
tags: ["typescript", "tutorial"],
// From BlogPost
title: "Learning TypeScript",
content: "TypeScript is awesome!",
authorId: 42
};
โ Benefits of Extension
- Code reuse: Define common properties once, use everywhere
- Maintainability: Change base interface, all extensions update
- Clear relationships: Shows inheritance hierarchy clearly
- DRY principle: Don't Repeat Yourself - define once!
Overriding Properties
When extending, you can make a property more specific (but not less specific!):
interface Animal {
name: string;
age: number;
species: string;
}
interface Dog extends Animal {
species: "dog"; // More specific! Must be exactly "dog"
breed: string;
bark(): void;
}
const dog: Dog = {
name: "Buddy",
age: 3,
species: "dog", // Must be "dog" exactly
breed: "Golden Retriever",
bark() {
console.log("Woof!");
}
};
// This won't work:
// const invalidDog: Dog = {
// species: "cat" // โ Error! Must be "dog"
// };
Chain of Extension
Interfaces can extend other extended interfaces, creating a hierarchy:
// Base
interface Entity {
id: number;
createdAt: string;
}
// First level extension
interface User extends Entity {
name: string;
email: string;
}
// Second level extension
interface PremiumUser extends User {
subscriptionLevel: "gold" | "platinum";
expiresAt: string;
}
// Third level extension
interface AdminUser extends PremiumUser {
permissions: string[];
canDeleteUsers: boolean;
}
// AdminUser has ALL properties from the entire chain!
const admin: AdminUser = {
// From Entity
id: 1,
createdAt: "2024-01-15",
// From User
name: "Alice",
email: "alice@example.com",
// From PremiumUser
subscriptionLevel: "platinum",
expiresAt: "2025-01-15",
// From AdminUser
permissions: ["read", "write", "delete"],
canDeleteUsers: true
};
id, createdAt] --> B[User
+ name, email] B --> C[PremiumUser
+ subscription, expiresAt] C --> D[AdminUser
+ permissions, canDelete] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#764ba2,stroke:#333,stroke-width:2px,color:#fff style C fill:#f093fb,stroke:#333,stroke-width:2px,color:#fff style D fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff
Real-World Pattern: Base Entity
A common pattern is creating a base entity that all database models extend:
// Base entity all database records share
interface BaseEntity {
readonly id: number;
readonly createdAt: string;
readonly updatedAt: string;
isDeleted: boolean;
}
// Specific entities extend the base
interface User extends BaseEntity {
name: string;
email: string;
passwordHash: string;
}
interface Product extends BaseEntity {
name: string;
price: number;
sku: string;
inStock: boolean;
}
interface Order extends BaseEntity {
userId: number;
productIds: number[];
totalAmount: number;
status: "pending" | "shipped" | "delivered";
}
// All entities have id, createdAt, updatedAt, isDeleted automatically!
const user: User = {
id: 1,
createdAt: "2024-01-15",
updatedAt: "2024-01-15",
isDeleted: false,
name: "Alice",
email: "alice@example.com",
passwordHash: "hashed-password"
};
Combining Extension with Type Aliases
You can extend interfaces using type aliases with intersections:
interface User {
id: number;
name: string;
}
// Type alias extending interface
type AdminUser = User & {
permissions: string[];
role: "admin";
};
const admin: AdminUser = {
id: 1,
name: "Alice",
permissions: ["read", "write", "delete"],
role: "admin"
};
// You can also extend interface with type
interface PremiumUser extends User {
subscriptionLevel: "gold" | "platinum";
}
// And mix them!
type SuperAdmin = AdminUser & PremiumUser & {
canAccessBilling: boolean;
};
๐ก Best Practice: Use extension to create clear hierarchies. Start with general interfaces and extend them to create more specific ones. This makes your code easier to understand and maintain!
Index Signatures
Sometimes you don't know all the property names in advance. Maybe you're working with a dictionary, a cache, or dynamic data from an API. Index signatures let you describe objects with dynamic keys! ๐
What Are Index Signatures?
An index signature tells TypeScript: "This object can have any number of properties with keys of this type and values of that type." It's like saying "a dictionary where keys are strings and values are numbers."
๐ Definition
Index Signature: A way to describe objects with dynamic property names. It specifies the type of keys and the type of values for properties that aren't explicitly defined.
Basic Index Signature Syntax
// String keys, number values
interface NumberDictionary {
[key: string]: number;
}
const ages: NumberDictionary = {
alice: 30,
bob: 25,
charlie: 35
};
// Add more properties dynamically
ages.diana = 28; // โ
OK
ages.eve = 32; // โ
OK
// Access dynamically
console.log(ages["alice"]); // 30
console.log(ages.bob); // 25
// Type checking still works!
// ages.frank = "thirty"; // โ Error! Must be a number
Different Key Types
Index signatures can use string, number, or symbol as key types:
// String keys
interface StringIndex {
[key: string]: string;
}
const stringDict: StringIndex = {
name: "Alice",
city: "New York",
country: "USA"
};
// Number keys (array-like)
interface NumberIndex {
[index: number]: string;
}
const numberDict: NumberIndex = {
0: "first",
1: "second",
2: "third"
};
console.log(numberDict[0]); // "first"
console.log(numberDict[1]); // "second"
// Note: number keys are actually converted to strings in JavaScript!
// So [key: string] will also match number keys
Mixing Index Signatures with Known Properties
You can have both specific properties and an index signature:
interface UserCache {
// Known properties
count: number;
maxSize: number;
// Dynamic properties (user IDs)
[userId: string]: number | string; // Must include types of known properties!
}
const cache: UserCache = {
count: 3,
maxSize: 100,
// Dynamic user IDs
"user_123": 25,
"user_456": 30,
"user_789": 35
};
console.log(cache.count); // 3
console.log(cache["user_123"]); // 25
โ ๏ธ Important Rule
When mixing known properties with index signatures, the index signature type must include the types of all known properties. In the example above, [userId: string]: number | string includes both number (for count and maxSize) and string.
Readonly Index Signatures
Make dynamic properties read-only:
interface ReadonlyCache {
readonly [key: string]: number;
}
const cache: ReadonlyCache = {
user1: 100,
user2: 200
};
console.log(cache.user1); // 100
// Can't modify!
// cache.user1 = 150; // โ Error! Index signature is readonly
// cache.user3 = 300; // โ Error! Index signature is readonly
Real-World Examples
Example 1: Configuration Object
interface AppConfig {
// Core settings (always present)
appName: string;
version: string;
// Feature flags (dynamic)
[featureFlag: string]: boolean | string | number;
}
const config: AppConfig = {
appName: "MyApp",
version: "1.0.0",
// Dynamic feature flags
enableDarkMode: true,
maxUploadSize: 5000000,
apiEndpoint: "https://api.example.com"
};
Example 2: API Response with Metadata
interface ApiResponse {
data: T;
status: number;
timestamp: string;
// Additional metadata (unknown in advance)
[metadata: string]: any;
}
const response: ApiResponse = {
data: [
{ id: 1, name: "Alice", email: "alice@example.com" }
],
status: 200,
timestamp: "2024-01-15T10:30:00Z",
// Dynamic metadata
requestId: "req_123456",
serverRegion: "us-east-1",
processingTime: 45
};
Example 3: Translation Dictionary
interface Translations {
// Known translations
welcome: string;
goodbye: string;
// Any other translation keys
[key: string]: string;
}
const englishTranslations: Translations = {
welcome: "Welcome",
goodbye: "Goodbye",
// Dynamic translations
hello: "Hello",
thankyou: "Thank you",
yes: "Yes",
no: "No"
};
const spanishTranslations: Translations = {
welcome: "Bienvenido",
goodbye: "Adiรณs",
hello: "Hola",
thankyou: "Gracias",
yes: "Sรญ",
no: "No"
};
// Function that works with any translation object
function translate(key: string, translations: Translations): string {
return translations[key] || key; // Return key if translation missing
}
console.log(translate("hello", englishTranslations)); // "Hello"
console.log(translate("hello", spanishTranslations)); // "Hola"
Record Utility Type (Alternative)
TypeScript provides a built-in Record utility type that's often clearer than index signatures:
// Using index signature
interface UserAges {
[username: string]: number;
}
// Using Record (equivalent and more readable)
type UserAges2 = Record;
// Both work the same way
const ages1: UserAges = {
alice: 30,
bob: 25
};
const ages2: UserAges2 = {
charlie: 35,
diana: 28
};
// Record is especially useful with literal types
type UserRole = "admin" | "editor" | "viewer";
type Permissions = Record;
const permissions: Permissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"]
};
โ When to Use Index Signatures vs Record
- Index signatures: When you need to mix known properties with dynamic ones
- Record: When all properties are dynamic and have the same type
// Mix known + dynamic: use index signature
interface Config {
version: string;
[key: string]: string | number;
}
// All dynamic: use Record
type Cache = Record;
Index Signature Limitations
Index signatures have some important limitations to be aware of:
interface Dictionary {
[key: string]: number;
}
const dict: Dictionary = {
a: 1,
b: 2
};
// โ ๏ธ Accessing a missing property doesn't error!
console.log(dict.c); // undefined (no TypeScript error)
// This is by design - index signatures assume any string key is valid
// To be safe, check if the property exists
if ("c" in dict) {
console.log(dict.c);
}
// Or use optional chaining
console.log(dict["c"] ?? "default");
๐ก Best Practice: Use index signatures when you genuinely need dynamic keys. For known, fixed properties, always declare them explicitly - it's safer and more maintainable!
Hands-on Practice: User Management System
Time to combine everything we've learned! We'll build a complete user management system using interfaces, type aliases, extensions, and index signatures. This is real-world code you'd see in production! ๐
๐ฏ Project Goal
Create a type-safe user management system with roles, permissions, authentication, and activity tracking. We'll use all the concepts from this lesson!
Step 1: Define Base Types and Interfaces
// Type aliases for common types
type UserId = string;
type Timestamp = string;
type UserRole = "admin" | "moderator" | "user" | "guest";
type PermissionAction = "read" | "write" | "delete" | "manage";
// Base entity that all database records share
interface BaseEntity {
readonly id: string;
readonly createdAt: Timestamp;
readonly updatedAt: Timestamp;
}
// User profile information
interface UserProfile {
firstName: string;
lastName: string;
avatar?: string;
bio?: string;
website?: string;
}
// User contact information
interface ContactInfo {
email: string;
phone?: string;
address?: {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
};
}
// User preferences
interface UserPreferences {
theme: "light" | "dark" | "auto";
language: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
privacy: {
profileVisible: boolean;
showEmail: boolean;
showPhone: boolean;
};
}
Step 2: Create the Main User Interface
// Main user interface combining all pieces
interface User extends BaseEntity {
username: string;
passwordHash: string;
role: UserRole;
profile: UserProfile;
contact: ContactInfo;
preferences: UserPreferences;
isActive: boolean;
isVerified: boolean;
lastLoginAt?: Timestamp;
}
// Admin user with additional capabilities
interface AdminUser extends User {
role: "admin"; // More specific!
permissions: PermissionAction[];
canAccessAdminPanel: boolean;
managedUserIds: UserId[];
}
// Guest user (limited capabilities)
interface GuestUser extends Omit {
role: "guest";
sessionId: string;
expiresAt: Timestamp;
}
Step 3: Create Activity Tracking
// Activity log entry
interface ActivityLog extends BaseEntity {
userId: UserId;
action: string;
resource: string;
metadata: Record; // Dynamic metadata
ipAddress: string;
userAgent: string;
}
// User with activity tracking
interface UserWithActivity extends User {
activityLog: ActivityLog[];
// Method to log activity
logActivity(action: string, resource: string, metadata?: Record): void;
}
Step 4: Implement the User Management Class
class UserManager {
// Store users with dynamic keys (userId -> User)
private users: Record = {};
private activities: Record = {};
// Create a new user
createUser(userData: Omit): User {
const user: User = {
id: `user_${Date.now()}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...userData
};
this.users[user.id] = user;
this.logActivity(user.id, "user_created", "users", { username: user.username });
return user;
}
// Get user by ID
getUser(userId: UserId): User | undefined {
return this.users[userId];
}
// Get users by role
getUsersByRole(role: UserRole): User[] {
return Object.values(this.users).filter(user => user.role === role);
}
// Update user
updateUser(userId: UserId, updates: Partial): User | null {
const user = this.users[userId];
if (!user) {
return null;
}
const updatedUser: User = {
...user,
...updates,
id: user.id, // Can't change ID
createdAt: user.createdAt, // Can't change creation date
updatedAt: new Date().toISOString()
};
this.users[userId] = updatedUser;
this.logActivity(userId, "user_updated", "users", updates);
return updatedUser;
}
// Promote user to admin
promoteToAdmin(userId: UserId, permissions: PermissionAction[]): AdminUser | null {
const user = this.users[userId];
if (!user || user.role === "admin") {
return null;
}
const adminUser: AdminUser = {
...user,
role: "admin",
permissions,
canAccessAdminPanel: true,
managedUserIds: [],
updatedAt: new Date().toISOString()
};
this.users[userId] = adminUser;
this.logActivity(userId, "promoted_to_admin", "users", { permissions });
return adminUser;
}
// Log user activity
logActivity(
userId: UserId,
action: string,
resource: string,
metadata: Record = {}
): void {
const log: ActivityLog = {
id: `log_${Date.now()}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId,
action,
resource,
metadata,
ipAddress: "127.0.0.1",
userAgent: "TypeScript/5.0"
};
if (!this.activities[userId]) {
this.activities[userId] = [];
}
this.activities[userId].push(log);
}
// Get user activity
getUserActivity(userId: UserId): ActivityLog[] {
return this.activities[userId] || [];
}
// Get all users
getAllUsers(): User[] {
return Object.values(this.users);
}
// Get statistics
getStats(): {
totalUsers: number;
activeUsers: number;
verifiedUsers: number;
usersByRole: Record;
} {
const users = this.getAllUsers();
return {
totalUsers: users.length,
activeUsers: users.filter(u => u.isActive).length,
verifiedUsers: users.filter(u => u.isVerified).length,
usersByRole: {
admin: users.filter(u => u.role === "admin").length,
moderator: users.filter(u => u.role === "moderator").length,
user: users.filter(u => u.role === "user").length,
guest: users.filter(u => u.role === "guest").length
}
};
}
}
Step 5: Use the System
// Create the user manager
const userManager = new UserManager();
// Create a regular user
const alice = userManager.createUser({
username: "alice",
passwordHash: "hashed_password_123",
role: "user",
profile: {
firstName: "Alice",
lastName: "Smith",
bio: "Software developer"
},
contact: {
email: "alice@example.com",
phone: "555-1234"
},
preferences: {
theme: "dark",
language: "en",
notifications: {
email: true,
push: true,
sms: false
},
privacy: {
profileVisible: true,
showEmail: false,
showPhone: false
}
},
isActive: true,
isVerified: true
});
console.log("Created user:", alice.username);
// Update user
const updatedAlice = userManager.updateUser(alice.id, {
profile: {
...alice.profile,
bio: "Senior software developer"
}
});
// Promote to admin
const adminAlice = userManager.promoteToAdmin(alice.id, ["read", "write", "delete", "manage"]);
if (adminAlice) {
console.log(`${adminAlice.username} is now an admin with permissions:`, adminAlice.permissions);
}
// Create more users
userManager.createUser({
username: "bob",
passwordHash: "hashed_password_456",
role: "moderator",
profile: { firstName: "Bob", lastName: "Jones" },
contact: { email: "bob@example.com" },
preferences: {
theme: "light",
language: "en",
notifications: { email: true, push: false, sms: false },
privacy: { profileVisible: true, showEmail: true, showPhone: false }
},
isActive: true,
isVerified: true
});
// Get statistics
const stats = userManager.getStats();
console.log("User Statistics:", stats);
// Get activity log
const aliceActivity = userManager.getUserActivity(alice.id);
console.log(`Alice's activity (${aliceActivity.length} entries):`, aliceActivity);
โ Console Output:
Created user: alice
alice is now an admin with permissions: ["read", "write", "delete", "manage"]
User Statistics: {
totalUsers: 2,
activeUsers: 2,
verifiedUsers: 2,
usersByRole: { admin: 1, moderator: 1, user: 0, guest: 0 }
}
Alice's activity (3 entries): [...]
Your Challenge: Extend the System
๐๏ธ Exercise: Add Team Management
Challenge: Create interfaces and methods to manage teams of users.
- Create a
Teaminterface with properties: id, name, memberIds, ownerId, createdAt - Add methods to UserManager:
createTeam,addUserToTeam,removeUserFromTeam - Create a
TeamMemberinterface that extendsUserwith team-specific properties
๐ก Hint
Think about using:
- A
Record<TeamId, Team>to store teams - Interface extension for
TeamMember extends User - Index signatures for flexible team metadata
๐ Excellent Work!
You've built a production-quality user management system using interfaces, type aliases, extensions, and all the concepts from this lesson. This is exactly the kind of code you'll write in real applications! ๐
Best Practices
You've learned a lot! Now let's solidify that knowledge with the best practices that will make you a TypeScript pro. ๐
โ Do's: Good Interface Habits
1. Use Clear, Descriptive Names
// โ Vague
interface Data {
info: string;
val: number;
}
// โ
Clear
interface UserProfile {
username: string;
age: number;
}
2. Keep Interfaces Focused and Small
// โ God interface - too much in one place
interface User {
// Identity
id: number;
username: string;
// Contact
email: string;
phone: string;
// Preferences
theme: string;
language: string;
// ... 50 more properties
}
// โ
Composed interfaces - single responsibility
interface UserIdentity {
id: number;
username: string;
}
interface UserContact {
email: string;
phone?: string;
}
interface UserPreferences {
theme: string;
language: string;
}
interface User extends UserIdentity, UserContact {
preferences: UserPreferences;
}
3. Use Readonly for Immutable Properties
// โ
Good: ID and timestamps should never change
interface Entity {
readonly id: string;
readonly createdAt: string;
name: string; // Can be updated
}
4. Make Optional Properties Truly Optional
// โ Everything optional - too permissive
interface User {
id?: number;
name?: string;
email?: string;
}
// โ
Only truly optional things are optional
interface User {
id: number; // Always required
name: string; // Always required
email: string; // Always required
phone?: string; // Truly optional
middleName?: string; // Truly optional
}
5. Document Complex Interfaces
/**
* Represents a user in the system
* @property id - Unique identifier (immutable)
* @property role - User's permission level
* @property lastLoginAt - ISO timestamp of last login (undefined if never logged in)
*/
interface User {
readonly id: number;
name: string;
role: "admin" | "user";
lastLoginAt?: string;
}
โ Don'ts: Common Mistakes
1. Don't Use any in Interfaces
// โ Defeats the purpose
interface User {
id: number;
data: any; // What is this? Who knows!
}
// โ
Be specific
interface User {
id: number;
metadata: Record;
}
2. Don't Create Overly Nested Structures
// โ Too nested - hard to work with
interface User {
profile: {
personal: {
name: {
first: string;
middle?: string;
last: string;
};
};
};
}
// โ
Flatten or extract interfaces
interface FullName {
first: string;
middle?: string;
last: string;
}
interface User {
name: FullName;
// Other flat properties
}
3. Don't Mix Interfaces and Types Inconsistently
// โ Inconsistent
interface User { } // Using interface
type Admin = { }; // Using type
interface Moderator { } // Back to interface
// โ
Consistent pattern
interface User { }
interface Admin extends User { }
interface Moderator extends User { }
// Or use types consistently
type User = { };
type Admin = User & { };
type Moderator = User & { };
๐ก Pro Tips
Tip 1: Use Utility Types
TypeScript provides built-in utility types - use them!
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Pick only certain properties
type PublicUser = Pick;
// Omit sensitive properties
type SafeUser = Omit;
// Make all properties optional
type UserUpdate = Partial;
// Make all properties required
type CompleteUser = Required;
Tip 2: Leverage Type Guards
interface Admin {
role: "admin";
permissions: string[];
}
interface User {
role: "user";
name: string;
}
// Type guard function
function isAdmin(user: Admin | User): user is Admin {
return user.role === "admin";
}
function handleUser(user: Admin | User) {
if (isAdmin(user)) {
// TypeScript knows user is Admin here
console.log(user.permissions);
}
}
Tip 3: Use Discriminated Unions
// Each type has a unique discriminator
interface Success {
status: "success";
data: any;
}
interface Error {
status: "error";
message: string;
}
interface Loading {
status: "loading";
}
type State = Success | Error | Loading;
function handleState(state: State) {
// TypeScript narrows the type based on status
switch (state.status) {
case "success":
console.log(state.data); // TypeScript knows data exists
break;
case "error":
console.log(state.message); // TypeScript knows message exists
break;
case "loading":
console.log("Loading..."); // No additional properties
break;
}
}
Summary
๐ Key Takeaways
- Interfaces define the structure of objects - they're like blueprints
- Type aliases can represent any type - primitives, unions, tuples, objects
- Optional properties (?) may or may not be present
- Readonly properties cannot be changed after initialization
- Interfaces can extend other interfaces, building hierarchies
- Type aliases use intersection (&) to combine types
- Index signatures allow dynamic property names
- Declaration merging works with interfaces, not type aliases
- Use interfaces for objects, type aliases for unions/tuples
- Keep interfaces focused - single responsibility principle applies!
Quick Reference Table
| Feature | Syntax | Use Case |
|---|---|---|
| Basic Interface | interface User { name: string } |
Define object structure |
| Optional Property | age?: number |
Property may be absent |
| Readonly Property | readonly id: number |
Immutable property |
| Type Alias | type ID = string | number |
Name for any type |
| Interface Extension | interface Admin extends User |
Inherit properties |
| Type Intersection | type Admin = User & { ... } |
Combine types |
| Index Signature | [key: string]: number |
Dynamic properties |
| Record Type | Record<string, number> |
Object with dynamic keys |
Decision Tree
๐ Additional Resources
- TypeScript Handbook: Object Types
- TypeScript Handbook: Interfaces
- TypeScript Handbook: Type Aliases
- TypeScript Utility Types
๐ What's Next?
In the next lesson, we'll explore Functions in TypeScript. You'll learn how to:
- Type function parameters and return values
- Use optional and default parameters
- Work with rest parameters
- Create function overloads
- Use generic functions for flexibility
Functions are the workhorses of any application - let's make them type-safe! ๐ช
Quick Quiz
๐ฏ Test Your Knowledge
Question 1: What's the main difference between interface and type?
Question 2: When should you use readonly?
Question 3: What does an index signature [key: string]: number mean?
๐ Fantastic Progress!
You've mastered interfaces and type aliases! You can now create complex, maintainable type structures for real-world applications. These skills are the foundation of professional TypeScript development.
Keep up the amazing work - you're becoming a TypeScript expert! ๐