-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
126: Balancing Type Safety and Developer Ergonomics
There is a constant tension in TypeScript between "perfect" type safety and "actually usable" code. If you push for 100% safety, you often end up with "type gymnastics"—complex generics and recursive types that make your teammates want to quit. If you lean too far into ergonomics, you end up with any everywhere, and you might as well be writing vanilla JavaScript.
I want to show you how to find the middle ground. We'll build a simple NotificationService that handles different types of alerts: Email, SMS, and Push. The goal is to ensure that if I'm sending an Email, I must provide an email address, but I don't want the developer calling the function to feel like they're fighting the compiler.
Starting with a strict Discriminated Union
The most "TypeScripty" way to start is with a discriminated union. It's clean, it's safe, and it's the gold standard for this kind of problem.
type EmailPayload = { type: 'email'; email: string; subject: string; body: string };
type SMSPayload = { type: 'sms'; phone: string; message: string };
type PushPayload = { type: 'push'; deviceId: string; alert: string };
type NotificationPayload = EmailPayload | SMSPayload | PushPayload;
function sendNotification(payload: NotificationPayload) {
switch (payload.type) {
case 'email':
console.log(`Sending email to ${payload.email}...`);
break;
case 'sms':
console.log(`Sending SMS to ${payload.phone}...`);
break;
case 'push':
console.log(`Sending push to ${payload.deviceId}...`);
break;
}
}
// This is great. It's totally type-safe.
sendNotification({ type: 'email', email: 'dev@example.com', subject: 'Hi', body: 'Hello!' });
At first glance, this is perfect. But here is where ergonomics start to slip. If I'm building a wrapper or a helper function around this service, I'm forced to pass that type string every single time. It feels redundant. If I already know I'm calling an "email" method, why am I manually typing type: 'email' inside an object?
The "Over-Engineering" Trap
I'll be honest: a few years ago, I tried to solve this by making the function generic. I thought, "I'll let the caller specify the type, and TypeScript will handle the rest." I wrote something like this:
// I thought this was clever. It wasn't.
function sendGenericNotification<T extends NotificationPayload>(payload: Omit<T, 'type'>, type: T['type']) {
const finalPayload = { ...payload, type } as T;
sendNotification(finalPayload);
}
// Now the caller does this:
sendGenericNotification({ email: 'dev@example.com', subject: 'Hi', body: 'Hello!' }, 'email');
I made a classic mistake here. I traded one piece of redundancy (the type property in the object) for another (passing the type as a separate argument). Worse, I had to use a type assertion (as T) because TypeScript couldn't prove that merging the object and the string would perfectly satisfy the union. I created a "clever" solution that was actually harder to maintain and slightly less safe.
Smoothing the API with Overloads
The real professional move here isn't to find a magic generic; it's to provide multiple "entry points" to the logic. We can use function overloads to provide a beautiful developer experience while keeping the internal implementation strictly typed.
// Overload signatures: This is what the developer sees
function notify(type: 'email', data: Omit<EmailPayload, 'type'>): void;
function notify(type: 'sms', data: Omit<SMSPayload, 'type'>): void;
function notify(type: 'push', data: Omit<PushPayload, 'type'>): void;
// The implementation: This is where the actual work happens
function notify(type: string, data: any) {
// We map the ergonomic input back to our strict union
const payload: NotificationPayload = { type, ...data };
sendNotification(payload);
}
// Look how much better this is to call:
notify('email', { email: 'dev@example.com', subject: 'Update', body: 'Done!' });
notify('sms', { phone: '555-0199', message: 'Your code is 1234' });
By separating the Public API (the overloads) from the Implementation, we've achieved the best of both worlds. The person using notify() gets perfect autocomplete and type checking, but they don't have to deal with the clunkiness of the discriminated union's structure. I'm basically acting as a translator between a "friendly" interface and a "strict" internal engine.
📋 Practical Task
Refactoring a Strict Theme Configuration
You have a theme system that is currently too rigid. The current implementation requires a mode property inside every config object, which is annoying for the developers building the theme files.
Current Code:
type LightTheme = { mode: 'light'; primaryColor: string; textColor: string };
type DarkTheme = { mode: 'dark'; primaryColor: string; textColor: string; contrastLevel: number };
type Theme = LightTheme | DarkTheme;
function applyTheme(config: Theme) {
console.log(`Applying ${config.mode} theme...`);
}
// Ergonomic failure: The dev has to manually add 'mode' every time
applyTheme({ mode: 'dark', primaryColor: '#000', textColor: '#fff', contrastLevel: 10 });
Your Task:
Refactor this using function overloads. Create a new function called setTheme that allows the developer to pass the mode as the first argument and the remaining properties as the second argument (without the mode property). Ensure that if 'dark' is passed, the contrastLevel is required, but if 'light' is passed, it is not.
There are no comments for now.