If 1 cup of coffee does not solve the error, repeat.
Welcome back to the “Advanced Union Type Techniques in TypeScript” series. Having covered Extract, Exclude, and Indexed Access Types, next up in this journey is a look at Discriminated Unions, one of my most favorite features from TypeScript. Why? Because I am lazy, and I prefer the type system and the IntelliSense, to answer my question at my current cursor: "If this is the case, what are the properties that I have access to?" This is especially handy when dealing with a considerable codebase. The last thing I want to do is open another file to go through the properties and check the conditions to see if there are properties that I think I have access to. And of course, even when I think I can access it, I want to make sure this assumption does not shoot me in the foot in production.
So what are the Discriminated Unions?
Discriminated unions in TypeScript use a common property, known as a discriminant, to distinguish between types within a union. What this means is, let’s imagine you open your wardrobe and see various types of clothing. You have tops and bottoms. Without discriminated unions, we might organize things as such:
type Clothing = {
material: string;
sleeveLength?: 'short' | 'long';
length?: 'short' | 'long';
type?: 'top' | 'bottom';
};
This approach is sound as long as you only have to deal with the condition once. However, it won’t take long before you start wrestling with it the next time you have to deal with these types. This is apparently the kind of wardrobe you wouldn’t want to open. “Stop, mom! Tell me how to organize this mess, so I can find my jogging pants next time!”
If you haven't ask the question, you're not ready for the answer
So, with discriminated unions, you can restore order to your wardrobe. Now, your clothes can be categorized like this:
type Top = {
type: 'top';
material: string;
sleeveLength: 'short' | 'long';
};
type Bottom = {
type: 'bottom';
material: string;
length: 'short' | 'long';
};
type Clothing = Top | Bottom;
In this example, the type
property is the discriminant. It clearly identifies whether a piece of clothing is a 'top'
or a 'bottom'
. When you use a Clothing
type, TypeScript's type system can use this discriminant to narrow down the type and offer more specific information or checks based on whether it's dealing with a Top
or Bottom
.
For instance, if you access an item from the Clothing
union, TypeScript knows that if type
is 'top'
, the item will also have sleeveLength
, and if type
is 'bottom'
, it will have length
. This simplifies managing different types, enhancing code safety and clarity.
Now, you can find your fancy pants rather easily; they’re in the 3rd drawer labeled “Bottom: short”.
Basic Example: Messaging App
Now, let’s be more serious. We want to build solutions, not just organize wardrobes. Consider a messaging app scenario, where messages can be either text, images, or system notifications. We use a type
property as the discriminant to clearly differentiate these message types:
type Message =
| { type: 'text'; content: string; sender: string }
| { type: 'image'; src: string; caption?: string }
| { type: 'system'; event: string };
When handling a message, wouldn’t it be neat if we could instantly discern the type of message we’re dealing with? Again, let’s see what we can do with discriminated unions:
function displayTextMessage(content: string, sender: string) {
console.log(`Text from ${sender}: ${content}`);
}
function displayImageMessage(src: string, caption?: string) {
console.log(`Image source: ${src}`, `Caption: ${caption ?? 'No caption'}`);
}
function handleSystemEvent(event: string) {
console.log(`System event: ${event}`);
}
function handleMessage(message: Message) {
switch (message.type) {
case 'text':
// TypeScript now knows `message` is of the type `{ type: 'text'; content: string; sender: string }`
displayTextMessage(message.content, message.sender);
break;
case 'image':
// `message` is now `{ type: 'image'; src: string; caption?: string }`
displayImageMessage(message.src, message.caption);
break;
case 'system':
// `message` is `{ type: 'system'; event: string }`
handleSystemEvent(message.event);
break;
}
}
// Correct usage
// Without TypeScript type error
const sampleMessage: Message = { type: 'text', content: 'Hello, TypeScript!', sender: 'User123' };
handleMessage(sampleMessage); // "Text from User123: Hello, TypeScript!"
// Incorrect usage
// With TypeScript type error
handleMessage({ type: 'text', sender: 'User123' }); // TypeScript Type Error: Argument of type '{ type: "text"; sender: string; }' is not assignable to parameter of type 'Message'.
// Incorrect usage
// With TypeScript type error
handleMessage({ type: 'system', src: 'image.png', caption?: 'spot the different pants' }); // TypeScript Type Error: Object literal may only specify known properties, and 'src' does not exist in type '{ type: "system"; event: string; }'.
In the first usage, TypeScript is content without any error because the argument matches the Message
type exactly. However, in the second usage, TypeScript raises a flag because the content
property is missing, which is crucial for a text
message. Lastly, in the third example, we mistakenly mix up system
message properties with those of an image
, leading to a type error.
In the handleMessage
function, TypeScript acts like a keen-eyed sorter. It scrutinizes the message
type in each case, neatly categorizing them. It's like having your messages sorted into different folders: texts, images, system alerts, ensuring that we only play around with the properties that make sense for each message type, accurately avoiding those all-too-common runtime surprises. It's like making sure you don't accidentally text a photo caption or caption a text message – keeping things tidy and error-free!
Advanced Example: Server-Side Error Handling
Now, let’s take a look at a more advanced solution: error handling in a server-side application. This is an oversimplified example, yet the concept is extracted from a real application that I’m currently working on. Here’s a brief on the problem: with the recent Next.js development, there was a need for some refactoring in the server-side logic of our application. This refactoring presented a unique challenge, particularly in the way we handle different types of errors. So, I thought I could use it to showcase the practicality of discriminated unions in a real-world setting.
In server applications like Next.js, handling different types of errors such as ConflictError
, UnauthorizedError
, and ValidationError
is crucial. Discriminated unions allow us to manage these errors in a structured and type-safe manner.
interface ConflictError extends Error {
type: 'ConflictError';
}
interface UnauthorizedError extends Error {
type: 'UnauthorizedError';
}
interface ValidationError extends Error {
type: 'ValidationError';
details: string;
}
// The union type for server errors
type ServerError = ConflictError | UnauthorizedError | ValidationError;
const createConflictError = (message: string): ConflictError => {
const error = new Error(message) as ConflictError;
error.type = 'ConflictError';
return error;
};
const createUnauthorizedError = (message: string): UnauthorizedError => {
const error = new Error(message) as UnauthorizedError;
error.type = 'UnauthorizedError';
return error;
};
const createValidationError = (message: string, details: string): ValidationError => {
const error = new Error(message) as ValidationError;
error.type = 'ValidationError';
error.details = details;
return error;
};
function handleServerError(error: ServerError) {
switch (error.type) {
case 'ConflictError':
console.error(`Conflict Error: ${error.message}`);
break;
case 'UnauthorizedError':
console.error(`Unauthorized Error: ${error.message}`);
break;
case 'ValidationError':
console.error(`Validation Error: ${error.message}, Details: ${error.details}`);
break;
default:
console.error(`Unhandled server error: ${error}`);
}
}
// Simulating server actions
function simulateServerAction(action: string): void {
switch (action) {
case 'updateUsername':
throw createValidationError('Invalid username', 'Username is too short');
case 'deleteUser':
throw createUnauthorizedError('User is not authorized to delete this account');
default:
throw createConflictError('User already exists');
}
}
// Test the error handling in server action
try {
simulateServerAction('updateUsername');
} catch (error) {
if (error instanceof Error && 'type' in error) {
handleServerError(error as ServerError);
} else {
console.error('Unknown error occurred', error);
}
}
This advanced example demonstrates the effective use of discriminated unions for error handling in server applications. By defining different server error types and employing factory functions, we create a structured and manageable approach to error handling.
The handleServerError
function utilizes TypeScript's type checking to process different error types accurately, enhancing the readability and maintainability of our code.
Conclusion
Discriminated unions in TypeScript offer a streamlined approach to handling complex scenarios. This feature not only enhances code clarity but also aligns closely with logical decision-making processes. It’s a practical tool that brings precision to our code, ensuring that each part of the union is handled correctly based on its unique characteristics. We ask, “What kind of message is this?” and respond accordingly. TypeScript’s discriminated unions let us code in a way that mirrors this logical thought process, which is a testament to how the language’s design aligns with human reasoning.
This post has demonstrated the practicality and importance of discriminated unions in TypeScript, especially for complex state management and handling diverse data structures. By leveraging discriminated unions, we can write more predictable and error-resistant code.
As we continue with this series, we’ll delve further into TypeScript’s advanced aspects, enhancing our skills and understanding. Stay tuned for more deep dives into TypeScript’s capabilities, equipping you to make the most of this powerful language in your projects.
Again, see you in the next one 👋! this is the 1099th cup…