04. Interfaces and Type System
๐จ Master TypeScript's powerful type system! Learn interfaces, optional & readonly properties, interface extension, type aliases, class implementation, index signatures, mapped types, and hybrid types for robust applications! ๐ก
What we will learn in this post?
- ๐ Defining Interfaces
- ๐ Optional and Readonly Properties
- ๐ Extending Interfaces
- ๐ Interfaces vs Type Aliases
- ๐ Implementing Interfaces in Classes
- ๐ Index Signatures and Mapped Types
- ๐ Hybrid Types and Function Interfaces
Meet TypeScript Interfaces: Blueprints for Your Objects! ๐จ
Ever wish you had a clear blueprint before building something? Thatโs what TypeScript interfaces are for your JavaScript objects! Think of them as contracts that define the exact shape an object should have. They tell you precisely what properties it must contain and what methods it can perform, along with their types.
Whatโs an Interface? ๐ ๏ธ
Using the interface keyword, you define a custom type blueprint. This isnโt just about property types like name: string or age: number; you can also specify method signatures, like greet(): string. TypeScript uses โstructural typing,โ meaning if an object looks like an interface (has all its required members), it is compatible โ no explicit implements keyword needed! This enables powerful contract-based programming, ensuring your code stays consistent and predictable.
Quick Example & Naming ๐ก
1
2
3
4
5
6
7
8
9
10
11
interface UserProfile {
name: string;
age: number;
greet(): string; // Method signature
}
const user: UserProfile = {
name: "Alice",
age: 30,
greet() { return `Hello, I'm ${this.name}!`; }
};
- Naming Convention: Interfaces usually start with a capital letter, like
UserProfile. TheIprefix (e.g.,IUserProfile) is optional and a matter of team preference.
Interface as a Contract ๐ค
classDiagram
direction LR
class UserProfile {
<<interface>>
๐ name string
๐ข age number
๐ greet() string
}
class MyUserObject {
๐ name string
๐ข age number
๐ greet() string
}
MyUserObject --|> UserProfile : โ
fulfills contract
style UserProfile fill:#6b5bff,stroke:#4a3f6b,color:#fff,stroke-width:3px
style MyUserObject fill:#43e97b,stroke:#38f9d7,color:#fff,stroke-width:3px
This diagram shows how MyUserObject implicitly โfulfills the contractโ defined by UserProfile because it has the required properties and method.
Interfaces: Flexible & Safe Data Structures! โจ
Hey there! Letโs explore some cool TypeScript interface features that make your code more robust and predictable.
Optional Properties: The โ?โ Symbol ๐คทโโ๏ธ
Sometimes, an object might not always have a specific property. Thatโs where the ? symbol shines!
- What it does: Marks a property as optional.
- Example:
interface User { name: string; email?: string; } - You can create a
Userobject with or without anemail, and TypeScript wonโt complain! - When to use: For configuration objects or fields that arenโt strictly mandatory.
Readonly Properties: Unchangeable Once Set ๐
Want a property that can only be set when the object is created and then never changed?
- What it does: Use the
readonlymodifier. - Example:
interface Product { readonly id: number; name: string; } - Once
idis assigned, trying to reassign it will cause a TypeScript error. - Immutability Pattern: Great for ensuring data integrity, like a unique identifier that should never change.
graph LR
A["๐๏ธ Create Object"]:::style1 --> B["๐ Set readonly"]:::style2
B --> C["โ
Value Assigned"]:::style3
C -- "Attempt Change" --> D["โ TS Error!"]:::style4
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style4 fill:#ff9800,stroke:#f57c00,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
Index Signatures: Dynamic Property Names ๐ท๏ธ
When you donโt know all property names beforehand, like in a dictionary or map:
- What it does: Allows dynamic property names based on a key type.
- Example:
interface StringMap { [key: string]: string; } - This means any string property added to
StringMapmust have a string value. - When to use: For flexible data structures where keys are generated dynamically.
Interface Inheritance: Extending Your Types โจ
Interface inheritance in TypeScript is a fantastic way to build powerful, reusable type hierarchies. By using the extends keyword, a new interface can inherit all the properties and methods from existing interfaces, helping you keep your code clean and organized. Itโs like building specialized blueprints from general ones!
Single Inheritance: Building Up ๐งฑ
You can create a more specific interface from a more general one.
1
2
3
4
5
6
7
interface Shape {
color: string;
}
interface Circle extends Shape { // Circle gets 'color' from Shape
radius: number;
}
// A 'Circle' now needs both 'color' and 'radius'.
Multiple Inheritance: Mixing & Matching ๐งฉ
Interfaces can extend multiple interfaces at once! This allows you to combine features from various โparentโ interfaces into a single, comprehensive type.
1
2
3
4
5
6
7
8
9
10
interface Loggable {
log(): void;
}
interface Savable {
save(): void;
}
interface Persistable extends Loggable, Savable { // Combines both!
id: string;
}
// 'Persistable' now requires 'log()', 'save()', and 'id'.
Overriding Properties: Keeping it Consistent โ
You can redefine a property inherited from a parent interface, but its type must be compatible (meaning it can accept all values the parentโs type could, or more).
1
2
3
4
5
6
7
interface Base {
id: string;
}
interface Enhanced extends Base {
id: string | number; // Compatible! string is assignable to string | number.
data: any;
}
This flexibility lets you build complex and clear type systems with ease.
Visualizing Inheritance ๐ณ
graph TD
A["๐ถ Shape"]:::style1 --> B["โญ Circle"]:::style2
C["๐ Loggable"]:::style3 --> E["๐พ Persistable"]:::style4
D["๐พ Savable"]:::style5 --> E
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style4 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style5 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
Interfaces vs. Type Aliases: A Friendly Chat! ๐ค
Choosing between interface and type in TypeScript can feel tricky, but itโs simpler than you think! Both help define custom types, yet they have distinct superpowers.
Meet the Interfaces! ๐
Interfaces are fantastic for defining object shapes and for classes to implement. Their key features are extends (like inheritance) and declaration merging. This means you can declare the same interface multiple times, and TypeScript will combine their properties!
1
2
3
4
5
6
7
interface User {
name: string;
}
interface User { // Declaration merging!
age: number;
}
// User now has 'name' and 'age'
Hello, Type Aliases! ๐ท๏ธ
Type aliases, using the type keyword, are incredibly versatile. They can represent any type โ primitives, unions (string | number), intersections (TypeA & TypeB), tuples, and even object shapes. They cannot be declaration-merged or directly extended in the same way as interfaces.
1
2
3
4
5
6
type ID = string | number; // A union type
type Point = [number, number]; // A tuple type
type UserProfile = { // An object shape
name: string;
age: number;
};
When to Pick Which? ๐ค
- Interfaces: Choose for defining object shapes when you anticipate extension (
extends) or declaration merging (e.g., in a library). - Type Aliases: Use for unions (
string | number), intersections (TypeA & TypeB), primitives (type ID = string;), or tuples. They offer more flexibility for complex type compositions.
Best Practices! โจ
For object shapes, generally start with interface. For anything else, like type ID = string | number;, type is your go-to!
graph TD
A["๐ฏ Define Type"]:::style1 --> B{"โ Object Shape?"}:::style2
B -- "Yes" --> C{"๐ Needs Merging?"}:::style3
C -- "Yes" --> D["๐ข Use Interface"]:::style4
C -- "No" --> E["๐ก Either Works"]:::style5
B -- "No" --> F["๐ต Use Type Alias"]:::style6
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style4 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style5 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style6 fill:#ff9800,stroke:#f57c00,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
๐ฏ Real-World Example: API Response Handler with Interfaces
Production REST API handlers use interfaces to ensure type safety across the entire request-response cycle!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
// API Response interfaces
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: ApiError;
timestamp: number;
}
interface ApiError {
code: string;
message: string;
details?: Record<string, unknown>;
}
interface PaginatedResponse<T> extends ApiResponse<T> {
pagination: {
page: number;
pageSize: number;
total: number;
hasNext: boolean;
};
}
// User entities
interface User {
readonly id: string;
username: string;
email: string;
role: 'admin' | 'user' | 'guest';
createdAt: Date;
profile?: UserProfile;
}
interface UserProfile {
firstName: string;
lastName: string;
avatar?: string;
bio?: string;
}
// API Handler class
class UserApiHandler {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async fetchUsers(
page: number = 1,
pageSize: number = 10
): Promise<PaginatedResponse<User[]>> {
try {
const response = await fetch(
`${this.baseUrl}/users?page=${page}&pageSize=${pageSize}`
);
if (!response.ok) {
return {
success: false,
error: {
code: 'HTTP_ERROR',
message: `HTTP ${response.status}: ${response.statusText}`,
},
timestamp: Date.now(),
};
}
const data = await response.json();
return {
success: true,
data: data.users,
timestamp: Date.now(),
pagination: {
page: data.page,
pageSize: data.pageSize,
total: data.total,
hasNext: data.page * data.pageSize < data.total,
},
};
} catch (error) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
timestamp: Date.now(),
};
}
}
async createUser(userData: Omit<User, 'id' | 'createdAt'>): Promise<ApiResponse<User>> {
try {
const response = await fetch(`${this.baseUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: data.error,
timestamp: Date.now(),
};
}
return {
success: true,
data: data.user,
timestamp: Date.now(),
};
} catch (error) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
timestamp: Date.now(),
};
}
}
}
// Usage
async function main() {
const api = new UserApiHandler('https://api.example.com');
// Fetch users with pagination
const result = await api.fetchUsers(1, 20);
if (result.success && result.data) {
console.log(`โ
Fetched ${result.data.length} users`);
console.log(`๐ Page ${result.pagination.page} of ${Math.ceil(result.pagination.total / result.pagination.pageSize)}`);
result.data.forEach(user => {
console.log(` - ${user.username} (${user.role})`);
});
} else if (result.error) {
console.error(`โ Error: ${result.error.message}`);
}
}
// This pattern is used in:
// - Next.js API routes
// - Express.js TypeScript backends
// - GraphQL resolvers
// - tRPC procedures
๐ Unlocking Dynamic Types: Index Signatures & Mapped Types!
Ever wished your TypeScript objects could handle any property name you throw at them, or smartly transform existing types? Welcome to the powerful world of index signatures and basic mapped types! They offer incredible flexibility and safety, letting your code adapt to dynamic data with ease.
๐ Index Signatures: Flexible Dictionaries
An index signature ([key: string]: Type) defines types for objects with dynamic or unknown property names. Itโs perfect for creating โdictionariesโ where you know the key and value types, but not the exact key names beforehand.
- Use Cases:
- Configuration Objects:
interface Config { [key: string]: string | number; }for settings liketheme: "dark". - Data Maps: Handling API responses with unpredictable keys.
- Configuration Objects:
- Example:
1 2 3 4
interface AppSettings { [key: string]: string | number; // Allows any string key } const myConfig: AppSettings = { theme: "dark", fontSize: 16 };
โ๏ธ Basic Mapped Types: Type Transformers
Mapped types are like โfunctionsโ for types! They allow you to create new types by transforming properties of an existing type. TypeScript provides powerful built-in mapped types:
Partial<T>: Makes all properties inToptional (?). Ideal for update payloads where you only send changed fields.1 2
type User = { name: string; age: number; }; type PartialUser = Partial<User>; // { name?: string; age?: number; }
Readonly<T>: Makes all properties inTimmutable (readonly). Great for ensuring data isnโt accidentally modified.1 2
type ReadonlyUser = Readonly<User>; // { readonly name: string; readonly age: number; } // fixedUser.age = 31; // Error!
graph TD
A["๐ฆ Original Type T"]:::style1 --> B["โ๏ธ Mapped Type"]:::style2
B --> C["โจ Transformed Type"]:::style3
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
โ Why They Matter!
These features bring robustness and flexibility. Index signatures handle unpredictable data structures gracefully, while mapped types let you derive powerful new types from existing ones, enhancing type safety and reducing repetitive code. Embrace them for cleaner, more maintainable TypeScript!
Interfaces: When a Function is Also an Object! ๐ค
The Hybrid Powerhouse Explained ๐
Ever wished something could be both a function you call and an object with properties? In TypeScript, interfaces let you define this powerful combo! Think of it like a Swiss Army knife: itโs a tool you can use (call it) but also has different blades and features (its properties). An interface combines a functionโs (parameters) => returnType signature with standard property: type; definitions.
Real-World Magic โจ
This hybrid capability is super handy for creating flexible and intuitive APIs:
- jQuery-style APIs: Imagine
$()โ you call$('selector')to select elements, but also use$.ajax()for network requests. - Factory Functions with Metadata: A
createProduct()function that you call, yet also carries info about itself, likecreateProduct.version = "1.0.0". - Configuration Builders: A
config()function you call to get a configuration, but also offersconfig.setDefaults()methods.
A Quick Peek at the Code ๐งโ๐ป
Hereโs how an interface describes such an object:
1
2
3
4
5
interface JQueryLikeAPI {
(selector: string): HTMLElement[]; // Callable part
ajax: (url: string) => Promise<any>; // Property part
version: string; // Another property
}
This means anything adhering to JQueryLikeAPI can be called directly and has ajax and version properties!
Visualizing the Concept ๐ผ๏ธ
graph TD
A["๐ฆ JQueryLikeAPI"]:::style1 --> B["๐ Call: myAPI()"]:::style2
A --> C["๐ Property: .ajax"]:::style3
A --> D["๐ Property: .version"]:::style4
classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style3 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
classDef style4 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
linkStyle default stroke:#e67e22,stroke-width:3px;
Want to Dive Deeper? ๐
๐ฏ Real-World Example: Plugin System with Interface Extension
Modern applications use interface extension for flexible plugin architectures!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// Base plugin interface
interface Plugin {
readonly name: string;
readonly version: string;
initialize(): void;
destroy(): void;
}
// Lifecycle hooks interface
interface LifecycleHooks {
onBeforeInit?(): Promise<void>;
onAfterInit?(): Promise<void>;
onBeforeDestroy?(): Promise<void>;
onAfterDestroy?(): Promise<void>;
}
// Configuration interface
interface Configurable {
config: Record<string, unknown>;
updateConfig(newConfig: Partial<Record<string, unknown>>): void;
}
// Advanced plugin with all features
interface AdvancedPlugin extends Plugin, LifecycleHooks, Configurable {
readonly dependencies?: string[];
readonly priority: number;
}
// Logger plugin implementation
class LoggerPlugin implements AdvancedPlugin {
readonly name = 'logger';
readonly version = '1.0.0';
readonly priority = 100;
readonly dependencies = [];
config: Record<string, unknown> = {
level: 'info',
format: 'json',
};
async onBeforeInit(): Promise<void> {
console.log(`๐ [${this.name}] Preparing initialization...`);
}
initialize(): void {
console.log(`โ
[${this.name}] Initialized with config:`, this.config);
}
async onAfterInit(): Promise<void> {
console.log(`โจ [${this.name}] Post-initialization complete`);
}
updateConfig(newConfig: Partial<Record<string, unknown>>): void {
this.config = { ...this.config, ...newConfig };
console.log(`๐ [${this.name}] Config updated:`, this.config);
}
async onBeforeDestroy(): Promise<void> {
console.log(`๐จ [${this.name}] Preparing shutdown...`);
}
destroy(): void {
console.log(`๐ฏ [${this.name}] Destroyed`);
}
log(level: string, message: string): void {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
}
}
// Plugin manager
class PluginManager {
private plugins: Map<string, AdvancedPlugin> = new Map();
async register(plugin: AdvancedPlugin): Promise<void> {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin "${plugin.name}" already registered`);
}
// Check dependencies
if (plugin.dependencies) {
for (const dep of plugin.dependencies) {
if (!this.plugins.has(dep)) {
throw new Error(`Missing dependency: "${dep}" for plugin "${plugin.name}"`);
}
}
}
// Run lifecycle hooks
if (plugin.onBeforeInit) {
await plugin.onBeforeInit();
}
plugin.initialize();
if (plugin.onAfterInit) {
await plugin.onAfterInit();
}
this.plugins.set(plugin.name, plugin);
console.log(`๐ Plugin "${plugin.name}" registered`);
}
get(name: string): AdvancedPlugin | undefined {
return this.plugins.get(name);
}
async unregister(name: string): Promise<void> {
const plugin = this.plugins.get(name);
if (!plugin) return;
if (plugin.onBeforeDestroy) {
await plugin.onBeforeDestroy();
}
plugin.destroy();
if (plugin.onAfterDestroy) {
await plugin.onAfterDestroy();
}
this.plugins.delete(name);
console.log(`๐ Plugin "${name}" unregistered`);
}
listPlugins(): string[] {
return Array.from(this.plugins.keys());
}
}
// Usage
async function main() {
const manager = new PluginManager();
const logger = new LoggerPlugin();
await manager.register(logger);
// Use the plugin
const loggerPlugin = manager.get('logger') as LoggerPlugin;
loggerPlugin.log('info', 'Application started');
// Update configuration
loggerPlugin.updateConfig({ level: 'debug' });
console.log('\n๐ Registered plugins:', manager.listPlugins());
// Clean up
await manager.unregister('logger');
}
main();
// This pattern is used in:
// - VS Code extensions
// - Webpack plugins
// - Babel plugins
// - Rollup plugins
๐ฏ Real-World Example: Form Validation with Mapped Types
Production form libraries use mapped types for powerful type-safe validation!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// Utility mapped types
type ValidationRule<T> = {
validate: (value: T) => boolean;
message: string;
};
type ValidationRules<T> = {
[K in keyof T]?: ValidationRule<T[K]>[];
};
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: Partial<Record<keyof T, boolean>>;
isValid: boolean;
};
// User registration form
interface UserRegistrationForm {
username: string;
email: string;
password: string;
confirmPassword: string;
age: number;
terms: boolean;
}
// Form validator class
class FormValidator<T extends Record<string, any>> {
private rules: ValidationRules<T>;
constructor(rules: ValidationRules<T>) {
this.rules = rules;
}
validate(values: T): ValidationErrors<T> {
const errors: ValidationErrors<T> = {};
for (const key in this.rules) {
const fieldRules = this.rules[key];
if (!fieldRules) continue;
const fieldErrors: string[] = [];
const value = values[key];
for (const rule of fieldRules) {
if (!rule.validate(value)) {
fieldErrors.push(rule.message);
}
}
if (fieldErrors.length > 0) {
errors[key] = fieldErrors;
}
}
return errors;
}
isValid(values: T): boolean {
const errors = this.validate(values);
return Object.keys(errors).length === 0;
}
}
// Define validation rules
const registrationRules: ValidationRules<UserRegistrationForm> = {
username: [
{
validate: (val) => val.length >= 3,
message: 'Username must be at least 3 characters',
},
{
validate: (val) => /^[a-zA-Z0-9_]+$/.test(val),
message: 'Username can only contain letters, numbers, and underscores',
},
],
email: [
{
validate: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
message: 'Invalid email format',
},
],
password: [
{
validate: (val) => val.length >= 8,
message: 'Password must be at least 8 characters',
},
{
validate: (val) => /[A-Z]/.test(val),
message: 'Password must contain at least one uppercase letter',
},
{
validate: (val) => /[0-9]/.test(val),
message: 'Password must contain at least one number',
},
],
age: [
{
validate: (val) => val >= 18,
message: 'You must be at least 18 years old',
},
],
terms: [
{
validate: (val) => val === true,
message: 'You must accept the terms and conditions',
},
],
};
// Form manager
class Form<T extends Record<string, any>> {
private state: FormState<T>;
private validator: FormValidator<T>;
constructor(initialValues: T, rules: ValidationRules<T>) {
this.state = {
values: initialValues,
errors: {},
touched: {},
isValid: false,
};
this.validator = new FormValidator(rules);
}
setValue<K extends keyof T>(field: K, value: T[K]): void {
this.state.values[field] = value;
this.state.touched[field] = true;
this.validateField(field);
}
private validateField<K extends keyof T>(field: K): void {
const errors = this.validator.validate(this.state.values);
this.state.errors = errors;
this.state.isValid = Object.keys(errors).length === 0;
}
getState(): Readonly<FormState<T>> {
return this.state;
}
submit(): T | null {
// Mark all fields as touched
for (const key in this.state.values) {
this.state.touched[key] = true;
}
// Validate all fields
this.state.errors = this.validator.validate(this.state.values);
this.state.isValid = Object.keys(this.state.errors).length === 0;
if (this.state.isValid) {
return this.state.values;
}
return null;
}
}
// Usage
const registrationForm = new Form<UserRegistrationForm>(
{
username: '',
email: '',
password: '',
confirmPassword: '',
age: 0,
terms: false,
},
registrationRules
);
// Simulate user input
registrationForm.setValue('username', 'john_doe');
registrationForm.setValue('email', 'john@example.com');
registrationForm.setValue('password', 'SecurePass123');
registrationForm.setValue('age', 25);
registrationForm.setValue('terms', true);
const state = registrationForm.getState();
console.log('๐ Form State:', {
isValid: state.isValid,
errors: state.errors,
});
const result = registrationForm.submit();
if (result) {
console.log('โ
Form submitted successfully:', result);
} else {
console.log('โ Form has errors:', state.errors);
}
// This pattern is used in:
// - React Hook Form
// - Formik
// - VeeValidate (Vue)
// - Angular Reactive Forms
๐ฏ Hands-On Assignment: Build a Type-Safe Event System ๐
๐ Your Mission
Build a production-ready event emitter system using TypeScript interfaces, generic types, and mapped types for complete type safety!๐ฏ Requirements
- Create an
EventMapinterface that maps event names to their payload types:- Use index signature to allow any string key
- Values should be the event payload type
- Create an
EventEmitterclass with these methods:on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): voidoff<K extends keyof T>(event: K, handler: (payload: T[K]) => void): voidemit<K extends keyof T>(event: K, payload: T[K]): voidonce<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void
- Implement proper type safety:
- Event names must match keys in EventMap
- Payloads must match the type for that event
- Handlers must accept the correct payload type
- Create example event maps for:
- User events: login, logout, profileUpdate
- Application events: error, warning, info
- Add wildcard listener:
onAny(handler: (event: string, payload: any) => void) - Implement event history tracking (last 10 events)
- Add async event handlers with Promise support
- Write comprehensive unit tests
๐ก Implementation Hints
- Use
Map<string, Set<Function>>to store event handlers - Use generics to preserve type information:
<T extends EventMap> - Leverage
keyof Tfor type-safe event names - Use mapped types to create utility types like
EventHandlers<T> - Implement
onceby wrapping handler and callingoffafter first emit
๐ Example Starter Code
// Define your event map
interface AppEvents {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'app:error': { message: string; code: number };
'data:update': { key: string; value: unknown };
}
// Event emitter implementation
class EventEmitter<T extends Record<string, any>> {
private handlers: Map<keyof T, Set<Function>> = new Map();
private history: Array<{ event: keyof T; payload: any; timestamp: number }> = [];
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
off<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler);
}
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
const eventHandlers = this.handlers.get(event);
// Add to history
this.history.push({ event, payload, timestamp: Date.now() });
if (this.history.length > 10) {
this.history.shift();
}
if (eventHandlers) {
eventHandlers.forEach(handler => handler(payload));
}
}
once<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
const onceHandler = (payload: T[K]) => {
handler(payload);
this.off(event, onceHandler as any);
};
this.on(event, onceHandler as any);
}
getHistory(): ReadonlyArray<{ event: keyof T; payload: any; timestamp: number }> {
return this.history;
}
clear(): void {
this.handlers.clear();
this.history = [];
}
}
// Usage example
const emitter = new EventEmitter<AppEvents>();
// Type-safe event listeners
emitter.on('user:login', (data) => {
console.log(`โ
User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.on('app:error', (data) => {
console.error(`โ Error ${data.code}: ${data.message}`);
});
// Emit events with type checking
emitter.emit('user:login', {
userId: 'user123',
timestamp: Date.now()
});
emitter.emit('app:error', {
message: 'Database connection failed',
code: 500
});
// One-time listener
emitter.once('user:logout', (data) => {
console.log(`๐ User ${data.userId} logged out`);
});
console.log('\n๐ Event History:', emitter.getHistory());
๐ Bonus Challenges
- Level 2: Add namespaced events (e.g.,
user:*wildcard matching) - Level 3: Implement async event handlers with
Promise<void> - Level 4: Add event priority system (high priority handlers run first)
- Level 5: Create middleware system for event transformation
- Level 6: Add React hooks integration:
useEventListener
๐ Learning Goals
- Master generic interfaces with constraints ๐ฏ
- Use mapped types for type transformations โจ
- Leverage
keyoffor type-safe keys ๐ - Implement type-safe event emitters ๐ก
- Build production-ready event systems ๐
๐ก Pro Tip: This event emitter pattern is the foundation for Node.js EventEmitter, Socket.io, and React's synthetic event system!
Share Your Solution! ๐ฌ
Completed the project? Post your code in the comments below! Show us your TypeScript mastery! โจ๐
Conclusion: Master Type Safety with TypeScript Interfaces ๐
TypeScriptโs interface system, combined with optional properties, readonly modifiers, interface extension, type aliases, index signatures, and mapped types, provides a powerful foundation for building robust, maintainable applications. By mastering these concepts, you can create type-safe APIs, flexible plugin systems, and production-ready codebases that catch errors at compile time rather than runtime โ from React components to Node.js backends powering enterprise applications.