Susan Potter
software: Created / Updated

Using Conditional Types in TypeScript

Example of conditionally typed validators in TypeScript
Caption: Example of conditionally typed validators in TypeScript

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.

Frequently Asked Questions

What are conditional types in TypeScript? 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.
When should you use conditional types? You can use conditional types when you need to update different types of objects, but you don’t know which type or fields will be updated ahead of time. This allows the types to adapt based on runtime conditions.
How do conditional types work? Conditional types use the extends keyword to check the type parameters and adapt the resulting type accordingly. For example, T extends U ? X : Y will evaluate to type X if T extends U, and type Y otherwise.
What is a practical example of using conditional types? We can create a conditional UpdateObject type that ensures type safety when updating objects with unknown properties. By checking if the property exists in the updates, TypeScript can prevent bugs.
When aren't conditional types needed? If the Partial utility type meets your needs, you may not need a custom conditional type. Evaluate if the built-in types solve your use case before reaching for conditionals.
What are the key takeaways? Use conditional types to create flexible mappings between types. Apply conditionals where runtime checks are needed based on types. Also, lean on TypeScript’s existing utility types when possible.

If you enjoyed this content, please consider sharing via social media, following my accounts, or subscribing to the RSS feed.