DEV Community

Michał Podwórny
Michał Podwórny

Posted on

TypeScript tutorial by example: implementing a well-typed validation library

Throughout this article, we will be examining and explaining some of ValidTs's code. Even an experienced TypeScript user may learn a trick or two. The reader is expected to have a general understanding of the language.

TLDR

Here is a list of links to some interesting TypeScript features that we will be using:

Validation in general

When we deal with any external data source, we can make no assumptions about the data received. It is quite common to see a web server's private API just cast the result of JSON.parse to some known type, or even leave it as any. An example explanation for doing that may go as follows: "This is a private API anyway and the same team works on the client-side code". It is quite convenient when you are just hacking around, but not very scalable. Best case scenario, invalid client requests end up as "cannot read X of undefined" in server-side error reporting. Worst case, something unexpected happens.

JSON.parse has always returned any. However, I would say that with the introduction of unknown type to TypeScript it seems that unknown would be a better-fitting return type for it. any encourages people to use something in any way, while unknown requires some work. If you want to see an example of how a statically typed language handles JSON parsing, take a gander at Elm's JSON Decoders. The idea behind the ValidTs library is to allow the user to easily define validators that safely turn any into concrete types.

Result type

All validators return a result. It is either a success or an error. We use a tagged union to define it, as it is very easy for TypeScript to infer properly.

enum ResultKind { Ok, Err }

type Ok<T> = { kind: ResultKind.Ok; value: T };
type AnyOk = Ok<any>;
type Err<T> = { kind: ResultKind.Err; value: T };
type AnyErr = Err<any>;

type Result<O, E> = Ok<O> | Err<E>;
type AnyResult = AnyOk | AnyErr;

Note that an enum defined like this will use integers in place of Ok and Err.

With the introduction of conditional types, it is easy to turn Ok<number> | Err<"invalid_number"> into Ok<number> with FilterOk or Err<"invalid_number"> with FilterErr.

type FilterOk<T extends AnyResult> = Extract<T, { kind: ResultKind.Ok }>;
type FilterErr<T extends AnyResult> = Extract<T, { kind: ResultKind.Err }>;

We also define another helper that just turns Ok<number> into number or Err<"invalid_number"> into "invalid_number".

type UnwrapOk<O extends AnyOk> = O["value"];
type UnwrapErr<E extends AnyErr> = E["value"];

Instead of a comparison result.kind === ResultKind.Ok we might want to use a helper function. Here is the definition of our type guard.

const isOk = <R extends AnyResult>(result: R): 
  result is FilterOk<R> => result.kind === ResultKind.Ok;

With TypeScript 3.7 we can also define analogous assertions.

function assertOk<R extends AnyResult>(result: R): 
  asserts result is FilterOk<R> {
    if (!isOk(result)) { throw new Error("Expected Ok"); }
  }

Armed with those helpers we can progress to our validators.

Validator type

We define our validator as a function that accepts any value and returns some result.

type Validator<I, O extends AnyResult> = (input: I) => O;

The idea about returning a Result instead of a boolean to indicate the result of the validation is that we want to allow our validators to change their input and return the result of that change as their successful output. This will make them more flexible by allowing casting/coercion of input to happen inside them.

Again, using conditional types, we are able to get the input and output types of our validators whenever we need.

type ExtractValidatorI<V> = 
  V extends Validator<infer I, any> ? I : never;
type ExtractValidatorO<V> = 
  V extends Validator<any, infer O> ? O : never;

Simple validators

Let us start by implementing a simple equality validator. To implement any validator, all we need to do is to satisfy the Validator<I, O> interface listed above. The equality validator accepts any input. If the input matches the expected value, it returns Ok<T>. Otherwise, it will report Err<"equality_error">.

type EqOutput<T> = Ok<T> | Err<"equality_error">;

const eq = <T>(expectedValue: T): Validator<any, EqOutput<T>> =>
  (input) => input === expectedValue 
    ? ok(input) 
    : err("equality_error");

That is it. Now any value that succeeds the equality check will be correctly typed. For example:

const validator = eq("some_const_string" as const)
const validation = validator(<input>)

if (isOk(validation)) {
  // validation.value is correctly typed to "some_const_string"
} else {
  // validation.value is correctly typed to "equality_error"
}

Note the use of as const available from Typescript 3.4 onwards. Thanks to it, the expression "some_const_string" is typed as "some_const_string" instead of just string. It is a very useful tool for any constant value, not just strings.

Take a quick look at incl, number, string, boolean, optional and nullable to see other simple validator examples.

Complex validators

"Or" validator

Let us try to tackle the or validator first. Here is the usage example:

const validator = or(string, number, boolean)
const validation = validator(<input>)

if (isOk(validation)) {
  // validation.value is correctly typed to `string | number | boolean`
} else {
  // validation.value is correctly typed to
  // {
  //   kind: "all_failed",
  //   errors: Array<
  //     "string_error" | "number_error" | "boolean_error"
  //   >
  // }
}

As we can see, or validator constructor is a variadic function - it has infinite arity. Its return type is a Validator<OrInput, OrOutput>. To type OrInput and OrOutput, we need to look at the validators passed to the constructor.

Here is a trick: to turn the tuple [boolean, string] into a union type boolean | string (or an array Array<boolean | string> into boolean | string), you can pick [number] from it: [boolean, string][number]. We will use this to get combined Ok and Err types from all the different validators passed to or.

Let us now define the or validator constructor:

const or = <Vs extends AnyValidator[]>(...validators: Vs):
  Validator<OrInput<Vs>, OrOutput<Vs>> => {
    // (...)
  }

As promised, it is a variadic function that returns a validator. Using the trick mentioned above and our ExtractValidatorI helper, we can define the input of the combined validator as an alternative of all the validator inputs passed to the constructor:

type OrInput<Vs extends AnyValidator[]> = 
  ExtractValidatorI<Vs[number]>;

Typing the output is a bit more complicated. We want an alternative of all the successes or all the errors wrapped in "all failed" error. We can take advantage of all the helpers defined above: ExtractValidatorO, FilterOk, FilterErr and UnwrapErr. Take a look at the final result:

type OrOutput<Vs extends AnyValidator[]> = 
  OrOutputOk<Vs> | OrOutputErr<Vs>;
type OrOutputOk<Vs extends AnyValidator[]> = 
  FilterOk<ExtractValidatorO<Vs[number]>>;
type OrOutputErr<Vs extends AnyValidator[]> =
  Err<
    {
      kind: "all_failed",
      errors: Array<
        UnwrapErr<FilterErr<ExtractValidatorO<Vs[number]>>>
      >,
    }
  >;

That is it! We have just defined a function that accepts an infinite number of arguments and correctly infers the input, success and error types of the generated validator based on those arguments. Note how nicely it composes with all the other validator functions we have. Also note that there is nothing stopping us from passing any custom validator to or, even an anonymous function.

"And" validator

Our and validator works similarly to the && operator. It constructs a validator that reports the first error encountered. If no error occurs, the output of the last validator is returned. Each validator feeds its output as input to the next one. I am not too well-versed in functional programming, but I would say that it works not unlike Kleisli composition of the Either monad. Here is the usage example:

const validator = and(string, (str) => {
  // Note that `str` is typed as `string`
  const parsed = parseInt(str)

  return Number.isNan(parsed) 
    ? err("cast_integer_error" as const) 
    : ok(parsed)
})
const validation = validator("123")

if (isOk(validation)) {
  // validation.value is typed as `number` 
  // and has value of `123`
} else {
  // validation.value is typed as 
  // `"string_error" | "cast_integer_error"`
}

It is quite complicated to express the "each validator feeds its outputs as input to the next one" part. For instance, we want the hypothetical and(string, moreThan(3)) to fail at compile time, assuming that string validator outputs a value of type string and moreThan(3) expects an input of type number.

I have found no other way to achieve this other than by extensive use of function overloads and defining each possible case separately for every arity:

interface And {
  // (...)
  // case for arity 4
  // case for arity 3
  // case for arity 2
  // case for infinite arity
}

export const and: And = (...validators: any) => {
  // (...)
}

Here is what I have done for arity of two:

<
  I1, 
  O1 extends AnyResult, 
  I2 extends UnwrapOk<FilterOk<O1>>, 
  O2 extends AnyResult
>(v1: Validator<I1, O1>, v2: Validator<I2, O2>): 
  Validator<I1, O2 | FilterErr<O1>>

The important parts to see are I2 extends UnwrapOk<FilterOk<O1>> (which ensures that the second validator expects to receive the successful output of the previous validator as its input) and Validator<I1, O2 | FilterErr<O1>> (which tells us what the resulting validator expects and returns).

We cannot define a case for every arity. I have defined a compromise catch-all case to handle infinite arity at the expense of validating the part that "the next validator expects to receive the successful output of the previous validator as its input".

<Vs extends AnyValidator[]>(...validators: Vs): Validator<
  ExtractValidatorI<Vs[0]>, 
  FilterOk<ExtractValidatorO<LastTupleElem<Vs>>> | 
    FilterErr<ExtractValidatorO<Vs[number]>>
>;

As you can see, we have replaced I1 from the previous example with ExtractValidatorI<Vs[0]>. Since TypeScript 3.0 generic variadic arguments are treated as tuples. In the example above, the generic Vs type gets inferred as a tuple and we can pick the first element from it: Vs[0].

O2 | has been replaced with FilterOk<ExtractValidatorO<LastTupleElem<Vs>>> |. It takes the last element of the Vs tuple, extracts the output of that validator and filters its success. LastTupleElem is quite interesting here. To implement that, I have stolen a trick from SimpleTyped library.

type Prev<T extends number> = [-1, 0, 1, 2, 3, 4, 5, 6, 7, (...)
type Length<T extends any[]> = T["length"];
type LastTupleElem<T extends any[]> = T[Prev<Length<T>>];

There we go! We have got a very powerful tool to express a set of validations that can also include casting and coercing. We can define a whole pipeline to be performed on a particular value.

"Shape" validator

The last validator we will examine is the shape validator. It allows defining a validator based on the given object shape. As always, the type of successful and erroneous validation all get correctly inferred. For example:

const validator = shape({
  name: string,
  age: and(string, (str) => {
    const parsed = parseInt(str)

    return Number.isNan(parsed) 
      ? err("cast_integer_error" as const) 
      : ok(parsed)
  })
})
const validation = validator(<anything>)

if (isOk(validation)) {
  // validation.value is typed as `{ name: string, age: number}`
} else {
  // validation.value is typed as
  // {
  //   kind: "shape_error",
  //   errors: Array<
  //     { field: "name", error: "string_error" },
  //     { field: "age", error: "string_error" | 
  //       "cast_integer_error" },
  //   >
  // }
}

As seen from the usage, all revolves around the schema definition. We will soon find out what its type is. However, let us first define the shape validator constructor as a function that accepts a schema and returns a validator with its output inferred from the schema:

const shape = <S extends Schema>(schema: S): 
  Validator<any, ShapeOutput<S>> => (input) => { (...) }

As we see above, a Schema is just a mapping from field to field's validator. We can achieve that with an index type:

type Schema = { [field: string]: AnyValidator };

The ShapeOutput is defined as a union of ShapeOutputOk and ShapeOutputErr:

type ShapeOutput<S extends Schema> = 
  ShapeOutputOk<S> | ShapeOutputErr<S>;

The definition of ShapeOutputOk takes advantage of the helper functions we already know and mapped types:

type ShapeOutputOk<S extends Schema> = Ok<
  { [K in keyof S]: UnwrapOk<FilterOk<ExtractValidatorO<S[K]>>> }
>;

What we do with ShapeOutputErr is more complicated. Let us start with the end result:

type ShapeOutputErr<S extends Schema> =
  Err<
    {
      kind: "shape_error",
      errors: Array<{
        [K in keyof S]: {
          field: K,
          error: UnwrapErr<FilterErr<ExtractValidatorO<S[K]>>>,
        }
      }[keyof S]>,
    }
  >

What happens is the following:

  1. We have a schema:
{
  name: Validator<
    any, 
    Ok<string> | Err<"string_error">
  >,
  age: Validator<
    any, 
    Ok<number> | Err<"string_error"> | Err<"cast_integer_error">
  >,
}
  1. We turn it into:
{
  name: { 
    field: "name", 
    error: "string_error" 
  },
  age: { 
    field: "name", 
    error: "string_error" | "cast_integer_error" 
  },
}

by utilising:

{

    field: K,
    error: UnwrapErr<FilterErr<ExtractValidatorO<S[K]>>>,
  }
}
  1. Then we turn it into:
{ field: "name", error: "string_error" } |
  { field: "age", error: "string_error" | "cast_integer_error" }

by picking the fields with [keyof S].

  1. Lastly, we wrap it in Array<T>.

That would be all for this complicated case. With or, eq and shape you can do wonky stuff, for instance automatically infer a union type:

const reservationCommandValidator = or(
  shape({
    kind: eq("RequestTicketReservation" as const),
    ticketId: number
  }),
  shape({
    kind: eq("RevokeTicketReservation" as const),
    reservationId: string
  }),
  shape({
    kind: eq("ArchiveTicketReservation" as const),
    reservationId: string
  })
);

I can imagine a single backend endpoint handling a successfully validated reservation request with ease and confidence.

Check out some other complicated validators: all, array and dict.

Postlude

I hope that this proves useful to someone. I find myself benefit from the features described above quite often. The more you manage to change any into a concrete type, or string into something like "RequestTicketReservation", the more maintainable and bug-proof your codebase gets.

Top comments (0)