Validation with Type Guards and Mapped Types
Most dynamic codebases are full of brittle validation logic. Here's a solve that uses nothing but TypeScript's built-in capabilities.
Join the DZone community and get the full member experience.
Join For FreeSlightly enhanced version of the code is now on NPM and GitHub.
Having spent a significant portion of my programming career using dynamic languages, I understand the value of rapid prototyping and feedback that they provide. I've also seen enough dynamic code to know that most dynamic codebases are full of brittle validation logic that our brethren from the statically typed camp don't have to deal with. Well, that's not entirely true. They still have to deal with it, but I think they have an easier time because the compiler can help them. There are many solutions to dealing with this problem in the dynamic camp in the form of libraries and DSLs, but today I'm going to present a solution that uses nothing but TypeScript's built-in capabilities to help us build validators for POJOs (plain old JavaScript objects).
The ingredients of the solution are pretty simple, but when combined together, we get a nice way to validate plain old JavaScript objects that are serialized and sent over the wire as JSON.
The first ingredient is mapped types. These are types that depend on types in some way. We don't have full dependence like in higher-order typed lambda calculi, but it's close enough and works really well for the kinds of interfaces that show up in JavaScript. We'll use these to "strictify" the type definition of the JSON payload. The payload type will have nullable fields so that the compiler will complain if we accidentally use the type in our code without first checking the fields for null values. Probably easier to show with an example:
type Payload = {
f1?: string,
f2?: number,
f3?: {
f1?: string,
f2?: string[]
}
};
Assuming this is the structure of the payload, we will need to convert it to a stricter version by removing the nullable fields. This is where mapped types come to the rescue. We can make a generic type for "strictifying" types like the above one with the following generic mapped type:
type Strict<T> = {
[K in keyof T]-?: Exclude<Strict<T[K]>, undefined>
};
What's going on here is we are recursively removing the nullable fields and replacing them with their strict versions with Exclude
(which is defined in TypeScript's standard library). Applying the above generic type to our payload type will give us the following:
type StrictPayload = Strict<Payload>;
// type StrictPayload = {
// f1: string;
// f2: number;
// f3: Strict<{
// f1?: string | undefined;
// f2?: string[] | undefined;
// }>;
// }
Notice the recursive structure for the last field. I recommend experimenting with Strict
in the TypeScript playground to get a better feel for it. Try replacing the generic type with string
, string[]
, number
, boolean
, and any other type you can think of.
So why do we need this type? We need it because our validation function is going to return "strictified" version of whatever we pass in. More specifically, the signature of our validation function is going to be:
function validator<T>(i: unknown, validation: Validated<T>): i is Strict<T> {
// ...
}
So the two missing pieces are the body of the function (it's really a type guard) and the definition of Validated
. We need an extra ingredient for defining Validated
and that's conditional types.
The format for the conditional types is R extends S ? T : U
. In plain English, that says if R
is assignable to S
, then the resulting type is T
, and if that's not the case, then the resulting type is U
. It took me a while to understand the value of these things, but if you look at the definition of Exclude
it's using conditional types so they're very handy.
Now we can define Validated
:
type Validated<T> =
T extends symbol | boolean | string | number ? boolean :
{ [K in keyof T]-?: Validated<T[K]> };
It's again probably easier to use an example. If we apply this to our payload type, then we get the following:
type ValidatedPayload = Validated<Payload>;
// type ValidatedPayload = {
// f1: boolean;
// f2: boolean;
// f3: // ...
// };
Play with it in the TypeScript playground to understand what is going on. What we're trying to do is force assignment of boolean values to all the fields. You might already see where this is going. By passing the validation payload to the validation function, we can verify that all the fields are actually true. If one of the fields is not true, then that means that field wasn't validated, and we can deal with it accordingly by either logging a warning or throwing an exception to halt execution. I basically just described what the validation function will look like, so let's fill it in:
function allTrue(validation: any) {
if (typeof validation === "boolean") {
return validation;
}
for (const k of Object.keys(validation)) {
const value = validation[k];
if (!allTrue(value)) return false;
}
return true;
}
function validator<T>(i: unknown, validation: Validated<T>): i is Strict<T> {
return allTrue(validation);
}
It's just a translation of what I said. We recursively descend through each object and verify that each field was set to a true value, and if we run into a value that is not true, then we return false so that the type guard check fails. Try running the validator in the TypeScript playground with the payload example to see what happens:
const payload = JSON.parse(JSON.stringify({}));
const validation = {
f1: !!payload.f1,
f2: !!payload.f2,
f3: {
f1: !!(payload.f3 || {}).f1,
f2: [!!(payload.f3 || {}).f2] // This could have more than 1 element
}
};
if (validator<Payload>(payload, validation)) {
console.log('Received valid payload', payload);
} else {
console.error('Invalid payload', payload);
}
This is obviously not the end of the story because all we are doing is verifying that the fields are present. There is an obvious extension of this method that will also check the types. See if you can figure out how to do that. Here's a hint: it involves modifying Validated
to also return the type of primitive values, instead of just converting everything to a boolean value.
Published at DZone with permission of David Karapetyan, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments