Welcome, fellow developer. Today we embark on a [hopefully] helpful journey into the world of conditional types via TypeScript! I hope to impress upon you how we can levereage conditional types to offer improved type safety especially in cases where the type of an associated piece of data can be computed from the type of the original data that is polymorphic in nature.
A High-Level Understanding
Conditional types are like shape-shifters, capable of dynamically transforming and adapting types based on varying conditions. They bestow upon the developer the gift of creating flexible type mappings without compromising on how well-typed our definitions are, thus empowering our code with expressiveness and resilience.
In this introductory section I will show the mechanics of how to describe conditional types in TypeScript itself even if the examples are trivial and not useful so that I am not distracted explaining the syntax in more useful and involved examples later on.
import { option } from 'https://cdn.skypack.dev/fp-ts?dts';
Here I am just importing the Option<T>
parameterized type from my trusted friend fp-ts
,
a library I tend to use in TypeScript codebases when I am able. If you are unfamiliar with this type think of this as an alternative way of encoding a nullable value in a more type safe way.
type NumberValidator = {
minValue: option.Option<number>;
maxValue: option.Option<number>;
};
type StringValidator = {
minLength: option.Option<number>;
maxLength: option.Option<number>;
contains: option.Option<RegExp>;
matches: option.Option<RegExp>;
};
type NoopValidator = {};
We start of by defining the type NumberValidator
, which represents a validator for numeric values, and the StringValidator
type, which is a validator for string values. Both types have specific properties that define their validation rules.
The NumberValidator
type has minValue
and maxValue
properties, each wrapped in an Option<number>
type from fp-ts
. These options can hold either a number value or no value at all.
the StringValidator
type has minLength
, maxLength
, contains
, and matches
properties, all wrapped in Option<T>
types. These options allow us to define constraints such as minimum and maximum lengths, required patterns, and more.
We also have a NoopValidator
type, which represents a validator that doesn't perform any specific validations. It's essentially an empty object, signaling that no validation rules are applicable.
const slugValidator: Validator<string> = {
minLength: option.some(1),
maxLength: option.some(10),
matches: option.some(/^[a-z\-]+$/),
contains: option.none,
};
const titleValidator: Validator<string> = {
minLength: option.some(1),
maxLength: option.some(64),
matches: option.none,
contains: option.none,
};
// age validator for purchasing alcohol in any sane country
const ageValidator: Validator<number> = {
minValue: option.some(18),
maxValue: option.some(149),
};
Next up we define two instances of Validator<string>
: slugValidator
and titleValidator
. These objects use the Validator
type we defined later to determine their structure based on the type argument provided.
The slugValidator
object specifies some validation rules for a string field.
The titleValidator
object, on the other hand, focuses on validating a string field for a title. It requires a minimum length of 1, a maximum length of 64, and doesn't have any pattern-based constraints.
Lastly, we encounter an age validator object – ageValidator
. It's specifically designed to validate numeric values, representing the age of an individual for purchasing alcohol in a sane country (we like sanity!). This validator sets a minimum age of 18 and a maximum age of 149, making sure we don't encounter any age-related shenanigans.
type Field = number | string | unknown;
type Validator<T extends Field> = T extends number
? NumberValidator
: (T extends string ? StringValidator : NoopValidator);
To wrap up the initial example we define a type alias called Field
that can be either a number, a string, or an unknown type. Then, we define the Validator
type, which takes a generic argument T
extending Field
.
Here's where the conditional magic comes into play: using the extends
keyword, we check if T
extends number
. If it does, the type becomes NumberValidator
. If T
extends string
, the type becomes StringValidator
. And if none of the conditions match, we fall back to NoopValidator
. Notice the familiar ternary operator syntax for conditional values in this type expression.
In essence, these mechanics allow us to dynamically determine the structure and properties of the validator object based on the type we provide. It's like having different validator configurations tailored to the type of data we're validating – an incredibly powerful technique!
When to use Conditional Types
Picture this: you're tasked with updating different types of objects, but here's the catch—you don't know which type or fields of the type. This exactly where conditional types come to the rescue, swooping in like a superhero to save the day.
Yes, perhaps this is a little contrived to show the power of conditional types mixed with keyof
for a more involved example I covered how to use keyof in TypeScript in a previous post.
Now, let me take you on a journey through the magical realm of TypeScript, where we'll witness firsthand how conditional types can boost your code's type safety and eliminate those dreadful runtime bugs for a situation like this.
First things first, what are conditional types? Think of them as intelligent type definitions that adapt based on conditions. They allow us to model complex scenarios, such as handling updates to objects with unknown fields, with elegance and precision.
Imagine you have a function called updateObject
that takes an original object and an updates object. Traditionally, without conditional types, you might write something like this:
function updateObject(original: any, updates: any): any {
return { ...original, ...updates };
}
Sure, it gets the job done, but it's like playing with fire. It's untyped, leaving you vulnerable to runtime bugs that may lurk in the shadows. Spelling mistakes, non-existent fields—these errors can easily slip through the cracks, only to haunt you later.
Also, if you have read my previous post on the differences between any
versus unknown
in TypeScript you will know any
typing should only be done as a last resort in TypeScript.
Fear not, reader! Conditional types are here to save us from this chaos. Let's dive into the code and see how we can level up our type safety game.
We'll start by defining a conditional type called UpdateObject
. Brace yourself for the magic:
type UpdateObject<T, U> = {
[K in keyof T]: K extends keyof U ? U[K] : T[K];
};
Let's break the above down. The UpdateObject
type takes two generic parameters, T
and U
, representing the original object and the updates object, respectively. Now, pay close attention to the square brackets [K in keyof T]
.
This snippet introduces a loop that iterates over each property K
in T
, which is the key type of the original object. But here comes the real beauty—the conditional statement: K extends keyof U ? U[K] : T[K]
.
This line of code works its magic by checking if K
exists in the keys of U
, our updates object. If it does, TypeScript gracefully assigns the type of U[K]
to the property in the resulting object. However, if the property doesn't exist in U
, it falls back to the type of T[K]
, ensuring we maintain the original value.
Now that we have our powerful UpdateObject
type, let's put it into action by revamping our updateObject
function:
function updateObject<
T,
U extends Partial<T>
>(original: T, updates: U): T {
return { ...original, ...updates };
}
Bear with me, folks—we're almost there! The updateObject
function now receives two parameters: original
, representing the original object, and updates
, holding our updates object. But what's this <T, U extends Partial<T>>
magic?
Here, we're using TypeScript's generic parameters to ensure type safety. T
represents the original object's type, while U
extends Partial<T>
. This ensures that the updates
object can only contain a subset of properties from the original object, avoiding unexpected additions.
With our revamped function in place, we can now unleash the power of TypeScript's type system to catch bugs at compile-time, rather than chasing them through the treacherous land of runtime.
Let's say we have a Person
type:
type Person = {
name: string;
age: number;
address: string;
};
Using our updated function, we can confidently update a Person
object, knowing that TypeScript will guard us against any mishaps:
const person: Person = {
name: 'Alice',
age: 25,
address: '123 Main St'
};
const updatedPerson = updateObject(person, {
age: 26,
address: '456 Elm St'
});
Trying to pass a partial object for the updates
with a misspelled attribute will now result in a type error. Like the following:
const updatedPerson2: Person =
updateObject(person, { adress: 232, });
This will produce the type error:
Argument of type '{ adress: number; }' is not assignable
to parameter of type 'Partial<Person>'.
Object literal may only specify known properties, but
'adress' does not exist in type 'Partial<Person>'.
Did you mean to write 'address'?
Boom! With TypeScript's watchful eye, any misspelled field names or attempts to update non-existent fields will be caught right at compile-time. No more runtime surprises, my friends!
But wait, we didn't use UpdateObject<T, U> anywhere
Good eye, reader. This was pointed out to me after I published a draft for people to review. I realized in the second case the Partial<T>
type was all the magic this case needed. No need to get fancy with conditional types.
So, dear developers, embrace the power of TypeScript and awe inspiring types like Partial<T>
. Say goodbye to the unpredictable world of runtime bugs, and stride confidently towards a future of reliable and type-safe code.
Embrace the Power of Conditional Types but only when you really need to! :)
Well the moral of this story is that next time I will not sit on a half finished blog post for so long such that I lose all my train of thought on the more "motivating" example to use such that it is essentially an invalid example for what I wished to demonstrate.
Forgive me, reader and take care until next time where we explore mapped types and even combine it with conditional types that we just explored here.
If you enjoyed this content, please consider sharing this link with a friend, following my GitHub, Twitter/X or LinkedIn accounts, or subscribing to my RSS feed.