Skip to Content
Course content

126: Balancing Type Safety and Developer Ergonomics

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.