valand.dev

TypeScript Type Game: Builder Pattern to Build A Validation Framework

One of the useful arts in type-oriented programming is the utilization of a type system to safeguard business logic. TypeScript can be used in a way that the compiler catches unsound logic, thus preventing it to be compiled in the first place. Today, we are going to experiment to find out how deep the type system can be involved in safeguarding business logic.

The case we are going to solve today is making a validator framework -- a tool to build a validator.

Roughly we want something like this.

const validator = Validator.makeBuilder<string>() // the string must be a URL .addRule(CommonRules.StringMustBeURL.Rule) // the string must not be empty .addRule(CommonRules.StringNotEmpty.Rule) .build();

We want this validator to be special. When we write this:

const errors = validator.validate("somestring"); const firstError = errors.at(0);

We want the error types to show in the editor. If you notice, when constructing the validator, we add two rules, one is StringMustBeURL and the second is StringNotEmpty. That defines what is going to come out of the validate method.

3 error hint hint

The most important thing is we want the compiler to stop devs from writing useless condition checks.

4 error hint catch never condition

I'm gonna write some code below. The full lib code with examples can be cloned from GitHub - Kelerchian/valid-n-typed

Let's get working

First, we define the type of the validator. The validator should be able to validate a certain value and return a certain error. For that, we use TypeScript's type to define this constraint.

/** * Validator is an object that has the method validate * It also contains the type information of the value it * should be validating and the error type that it can * return */ export type Validator<Val, Err> = { validate: (value: Val) => Err[]; };

Just on the first snippet, we want to use a builder pattern to build the validator. It should support .addRule method which adds a rule and inject a new error type definition and build method, which builds the validator.

/** * To build a Validator, we need a Builder * ValidatorBuilder is an object that has addRule method and build method. */ export type ValidatorBuilder<Val, Err> addRule: unknown; build: unknown; };

Now since we are working in the realm of type, let's work on these two things:

  • Build method that produce a Validator

  • AddRule which can inject a new error type.

First, we'll deal with the low hanging fruit. Build method is supposed to produce a Validator, more specifically, one that has the same Val and Err. So, build method should be:

export type ValidatorBuilder<Val, Err> addRule: unknown; /** * build the validator object. All inserted error types will be * inherited into the validator object */ build: () => Validator<Val, Err>; };

Now, let's deal with addRule method. How do we inject an error type? The trick is to treat generic like a function. Both receive parameters. Instead of receiving a value as an argument like a function does, a generic parameter accepts type.

First, we want to define AddRule to help us think.

type AddRule<Val, Err> = (rule: unknown) => unknown

AddRule is a function that receives a Rule. AddRule has Val and Err. It represents the Val and Err that are defined in the ValidatorBuilder's type definition.

type AddRule<Val, Err> = (rule: Rule) => unknown

Rule type brings another type information: Val the value it is validating, and Err the error type it is going to inject. Err of Rule and Err of AddRule can be different, therefore we differentiate them by naming Rule's to NewErr

type AddRule<Val, Err> = (rule: Rule<Val, NewErr>) => unknown;

Now, the builder pattern is made by method returning the object's own type. So addRule should return ValidatorBuilder too. The twist is, the new ValidatorBuilder will have both Err and NewErr. In TypeScript we can write Err | NewErr. It means literally Err or NewErr. This is called a union.

So now we have this in the bigger picture.

export type ValidatorBuilder<Val, Err> = { /** * adds a validation rule to the builder * In addition, it also insert a new error type into the builder * This allows chaining of rules, for example: * builder. * addRule(StringNotEmpty) * addRule(StringMustBeURL) */ addRule: <NewErr>(newRule: Rule<Val, NewErr>) => ValidatorBuilder<Val, Err | NewErr>; build: () => unknown; };

New to this syntax? Let me explain.

  • <NewErr> before the function definition NewErr as a new type scoped in the context of the function definition

  • Rule<Val, NewErr> denotes a type we haven't defined, Rule, with two additional types as the payload Val and NewErr. Val here refers to the Val that belongs to ValidatorBuilder

  • => ValidatorBuilder<Val, Err | NewErr> means the function will return a different kind of ValidatorBuilder - one that has both Err and NewErr assigned to its Err.

Nice! We have solved the .addRule() chaining mechanism on the type level.

These are plain types, which means they don't do anything when compiled into JavaScript. Next, we are going to write the implementation of this type. But before that, we still have one missing definition. How would we define Rule here?

Let's get back to how we want programmers to write a rule. In the first snippet, we see CommonRules.StringMustBeURL.Rule, but what should it look like? Let's define one.

Suppose we want to make a validation rule saying that an empty string is invalid. The validation rule is a function that returns an error when the value is invalid and returns nothing when the value is valid. The error can be anything, but let's use a simple string right now.

The usage code should look roughly like this.

Validator.makeBuilder<string>() // the string must be a URL .addRule((str) => str.length > 0 ? null : "string-is-empty")

With this snippet, we can guess what Rule would look like. A rule is a function that receives a string as the value, return a string as the error, or a null if there is no error.

type Rule<string, string> = (value: string) => string | null

Replace <string, string> with <Val, Err>. This is less confusing because now we know which string is for which. Now we get a function that receives a value represented by Val, tests it, and return Err or null.

type Rule<Val, Err> = (value: Val) => Err | null

Val and Err can be anything. It can be string and string, it can be number and number, it can be object and symbol, and so on.

Writing the actual code

At this point, we have written 0 lines of runnable code. When the boss is asking for a demo, the boss is going to see an empty screen. That's not good, let's go back to work.

First, let's implement a validator. It should be easy. We'll use a curried object instead of class just because I like it that way. The boss isn't going to look.

const makeValidator = <Val extends any, Err extends any>( rules: Rule<Val, Err>[] = [] as Rule<Val, never>[] ): Validator<Val, Err> => { // ? };

Nice! We have created another 0 lines of runnable code. At least, now we have makeValidator a function that is also a constructor that returns Validator<Val, Err>.

<Val extends any, Err extends any> have a special meaning. It indicates that Val and Err are either of any type or a subset of any type. What I mean by subset is, that Val can be any type as long as it is included in any, which is literally any type.

  • As an analogy, when X is the subset of boolean it means X can be false, true, or false | true.

  • Another analogy, when x is the subset of 1 | 2 | 3, X is either 1, 2, 3, 1|2, 1|3, 2|3, or 1|2|3.

This might be confusing, but we'll be back here later. For now, let me explain about <X> behind a function. This syntax introduces a new type X into the context and enable X to be inferred.

See the function below:

const doSomething = <X>(x: X) => { // do something with X };

If you write a function call with a certain type, for example

doSomething("with this string")

TypeScript will infer X as a string.

If we write a similar function with two parameters with the same type and then call it with two different types, TypeScript will complain.

const doSomething = <X>(a: X, b: X) => { // do something with X }; doSomething(1, "x"); // ~~~ // error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. // Notes: explicitly defining the X type can make TypeScript happy doSomething<number | string>(1, "x") doSomething<1 | "x">(1, "x") doSomething<unknown>(1, "x") // These three lines will compile

In the context of our Validator code, the constructor's Val and Err will be inferrable. Now if you notice, the constructor function receives an array of rules, defaulting to a weird type.

(rules: Rule<Val, Err>[] = [] as Rule<Val, never>[]): Validator<Val, Err> => { // ? }

What is [] as Rule<Val, never>[]? [] is an empty array, an empty list of rules. never represents a type that will never happen. As in, there is no object/value in JavaScript's runtime that will fulfill the type never. None. Not even null and undefined.

With this default, if the function is called, it will return a validator with no rules, and the type Validator<Val, Err>. Poetic -- a validator with no rule is a validator that will never return an error.

Let's implement the validator real quick.

const makeValidator = <Val extends any, Err extends any>( /** * Contains a collection of validation rules the validator is going to use */ rules: Rule<Val, Err>[] = [] as Rule<Val, never>[] ): Validator<Val, Err> => { // Self referencing type. A convenience type Self = Validator<Val, Err>; // Using Self["validate"] is a short cut to refer to the type // of the property of Validator we defined early in this article // which is Validator.validate const validate: Self["validate"] = (value) => { const errors: Err[] = []; // Iterates over the rules to find a rule which the value // violates rules.forEach((rule) => { const error = rule(value); // error's type is null | Err // the line below checks if there is an error if (error !== null) { errors.push(error); } }); return errors; }; // Construct self and return const self: Self = { validate }; return self; };

In this function, we see the definition of the only method a validator has, validate. For each rule in rules that the Validator is supplied, which can produce Err, value, which is Val, is applied to the rule. If the rule returns a non-null value, it means the value is invalid for that particular rule. The Err is then collected into the variable errors which is Err[], a collection of Err.

Finally, writing the builder

Here's a skeleton code to make it clear to see what code we haven't written.

export const makeBuilder = <Val extends unknown, Err extends unknown = never>( rules: Rule<Val, Err>[] = [] ): ValidatorBuilder<Val, Err> => { // reference to Self type, convenient type Self = ValidatorBuilder<Val, Err>; const addRule: Self["addRule"] = ?; const build: Self["build"] = ?; // Construct and return self const self: Self = { addRule, build, }; return self; };

Again, here we have Val and Err that can be inferred. Err here is defaulted to never, just like the Validator counterpart, except that the defaulting is now done in the type layer.

(TypeScript's compiler complains when I don't include that part)

Let's deal with the low-hanging fruit again, the build function. Now that we have the makeValidator constructor, Val and Err from the makeBuilder definition, and rules that is supplied, we don't have to do anything other than calling the makeValidator construtor with what we have.

build is a function (() => ...) that calls the makeValidator with <Val, Err> as the type parameter and (rules) as the argument.

/** * Converts the builder into the validator. */ const build: Self["build"] = () => makeValidator<Val, Err>(rules);

Done!

Now to figure out addRule. Earlier we "suggested" the type injection when we are writing the type definition of the addRule property, but now we are going to explicitly do it again.

addRule is a function that receives a new kind of rule, one with <Val, NewErr> as its type parameter. Earlier we also have written the skeleton of makeBuilder constructor. We are going to write what is often called recursion, or a function that calls itself. In this case, it is a function addRule that calls its owner (ValidatorBuilder), an indirect recursion. The new received rule is incorporated with the previous rules and is given to the new ValidatorBuilder that is going to be created. The syntax [...rules, newRule] means "Create a new array, fill it with the content of rules, and then add one more item, the newRule"

const addRule: Self["addRule"] = <NewError>(newRule: Rule<Val, NewError>) => makeBuilder<Val, Err | NewError>([...rules, newRule]);

Now, we can see when and where the NewErr is injected. It happens alongside the combining of the old rules and the new rule when the recursion happens.

Great, now let's see the whole code.

export const makeBuilder = <Val extends unknown, Err extends unknown = never>( /** * It receives rules a variable containing a collection of validation rules * It is assigned with Rule<Val, never>[] empty array, which semantically means * a list of rules which NEVER return an error * (technically an empty array is still a list) */ rules: Rule<Val, Err>[] = [] ): ValidatorBuilder<Val, Err> => { /** * Self referencing type. A convenience */ type Self = ValidatorBuilder<Val, Err>; /** * addRule function receives a new Rule. Here a new error type "NewError" is introduced * A new builder is created and returned. The twist is, this new builder has a new rule, * and a new error type embedded in it * <Err | NewError> indicates that OldError types are unionized with the NewError type. */ const addRule: Self["addRule"] = <NewError>(newRule: Rule<Val, NewError>) => makeBuilder<Val, Err | NewError>([...rules, newRule]); /** * Converts the builder into the validator. */ const build: Self["build"] = () => makeValidator<Val, Err>(rules); // Construct and return self const self: Self = { addRule, build, }; return self; };

So, what now?

None. We're done. If this is your first time writing a recursion, this sort of sensation just comes. It's gonna feel like you haven't finished anything, but actually, the whole thing clicks and completes itself. It's weird I know.

Can we show it to boss?

I think the boss would want a whole app, not just a validation framework. We haven't even written the actual validation code. But it's a great start!

By the way, let me show you a neat trick! TypeScript has a keyword called as const which will result in a narrowed type, akin to what extend does, although the narrowing is happening in a different place.

Let's say we have this line of code

const a = "a"; const b = "b" as const;

What's the difference between a and b?

None, both are string?

No actually. a is string. But b is "b"?

What do you mean b is b? You are not making any sense!

The type "b" is a literal string. A variable with the type string can be assigned with "b". A variable with the type "b" cannot be assigned with "a". It is only a subset of string. It is just like true and boolean. A true is a boolean, but a boolean is not always a true.

So this is a neat thing to have, considering we now have a type-strict validator thing. If you remember the method addRule in the ValidatorBuilder, it has a generic function parameter NewErr. When you write a new rule such as this

.addRule( (str: string) => str.includes("u") ? "u-shall-not-pass" as const : null )

It will assign "u-shall-not-pass" as the type of NewErr, therefore adding "x-must-not-pass" into the pool of Err in ValidatorBuilder. The type of the builder becomes ValidatorBuilder<string, OtherError | "u-shall-not-pass">.

As an alternative for code organization, it can also be written like this.

const UShallNotPass = "u-shall-not-pass" as const; const UShallNotPassRule: Rule<string, typeof UShallNotPass> = (str: string) => str.includes("u") ? "u-shall-not-pass" as const : null; // later .addRule(UShallNotPassRule);
that's all folks!

I realize that it is rare that I write TypeScript in detail. I hope this is helpful to whoever reads.