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:
unknown
type- tagged union
- conditional types
- type guards
- assertion functions
const
assertions- tuple type inference from generic rest parameters
- function overloads
- index types
- mapped types
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:
- We have a schema:
{
name: Validator<
any,
Ok<string> | Err<"string_error">
>,
age: Validator<
any,
Ok<number> | Err<"string_error"> | Err<"cast_integer_error">
>,
}
- 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]>>>,
}
}
- 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]
.
- 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)