Published on

Using TypeScript’s Mapped and Conditional Types

In this post I will demonstrate how to use TypeScript’s mapped and conditional types to enforce defining @hapi/joi validations corresponding to TypeScript’s types.

I gave a talk on the same topic. Check it out if you prefer consuming information as video/audio over text. This post demonstrates how to apply mapped and conditional types in practise, but not necessarily the best way to define validations.

The code examples assume TypeScript of 3.8 (but the functionality is, with minor changes, available since TypeScript 2.8) and @hapi/joi of version 16.1.

The Problem

TypeScript does not give any runtime type guarantees, and thus it can easily happen for a runtime value to differ from the type we specified for it. This typically happens when we work with data we do not own. For example, JSON input to a REST API can come in any form, independent of the type we define for it.

One way to bridge this gap is to validate inputs On this topic, check out Alexis King’s blogpost Parse, don’t validate. on the system boundary and ensure that the validated values correspond to their specified types.

However, when the validation definition is independent of the underlying type, we have no guarantee that the validations stay true to the type as the codebase evolves. Take the following example where the specified type and validation rules differ.

import * as joi from '@hapi/joi'

type Article = {
  title: string
  content: string
}

const ArticleSchema = joi.object({
  title: joi.number(),
})

const processArticle = async (article: Article): Promise<void> => {
  const validatedArticle = await ArticleSchema.validateAsync(article)
  // use validatedArticle
}

The defined schema has two issues It is easy to spot issues in an example of this size, but not necessarily in a codebase of larger size. Especially if validations are defined in a different place than the type.:

  1. It does not define validation for the content property.
  2. The type declaration declares title to be of type string, but the validation says that it has to be a number.

The Remedy

The remedy is to make a compile-time link between the underlying type and its validations. A link which:

  1. Checks that validations are defined for each property.
  2. Checks that validation rules correspond to the underlying type.
  3. Triggers a compile-time error if either of the checks fail.

To put it into TypeScript terms, we want to define a generic type ValidationSchema<T> which will ensure that any value of type ValidationSchema<T> defines validations for the generic object T. In the following example:

// Define the validation type
type ValidationSchema<T> = {
  // todo
}

type Article = {
  title: string
  content: string
}

const WrongArticleValidations: ValidationSchema<Article> = {
  title: joi.string(),
}

const CorrectArticleValidations: ValidationSchema<Article> = {
  title: joi.string(),
  content: joi.string(),
}

we should get a compilation error for WrongArticleValidations because it does not specify validation for property content.

Defining Validation for Each Property

Mapped types allow us to map over the properties of a type and:

As such, we can define a mapped type ValidationSchema1<T> which maps over the properties of its parameter T and which:

Lo and behold:

If you are confused about what the -? syntax means, take a look at the release notes of TypeScript 2.8 which explain the modifier. Thanks to my colleague Morten Andersen for pointing this out.

import { Schema } from '@hapi/joi'

type ValidationSchema1<T> = {
  [PropertyName in keyof T]-?: Schema
}

Note that the mapping between properties of ValidationSchema1<T> and T is bidirectional. ValidationSchema1<T> will require you to define all properties of T and will not allow you to define any extra properties.

Using this type prevents us from omitting a validation, but does not ensure that we define a validation of correct type. For that we have to use conditional types.

Defining Validations of Correct Type

As mapped types helped us ensure that we define validations for each and every property of a type, conditional types will help us ensure that we define validations of appropriate type. That is, we define a number validation for a number, a string validation for a string and so on.

Conditional types resolve to a different type, based on a type-level condition. We can then define a conditional PropertySchema<T> type resolving to different types, based on the type of its parameter. Lo and behold:

import { BooleanSchema, StringSchema } from '@hapi/joi' 

type PropertySchema<PropertyType> =
  PropertyType extends boolean ? BooleanSchema :
  PropertyType extends string ? StringSchema :
  // ...
  never

The Result

We can now combine the two types into the final ValidationSchema<T> type which implements the required functionality.

import {
  ArraySchema, BooleanSchema, NumberSchema,
  ObjectSchema, StringSchema, SymbolSchema
} from '@hapi/joi'

type PropertySchema<PropertyType> =
  PropertyType extends boolean ? BooleanSchema :
  PropertyType extends string ? StringSchema :
  PropertyType extends number ? NumberSchema :
  PropertyType extends symbol ? SymbolSchema :
  PropertyType extends ArrayLike<any> ? ArraySchema :
  PropertyType extends Record<string, any> ? ValidationSchema<PropertyType> :
  never

type ValidationSchema<T extends Record<string, any>> = {
  [PropertyName in keyof T]-?: PropertySchema<T[PropertyName]>
}

You can find this code on GitHub and you can jump right into using it with @hapi/joi.