loading...

Notes on TypeScript: Building a validation library

busypeoples profile image A. Sharif ・4 min read

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples in this post are based on TypeScript 3.7.2.


Basics

When building applications, we often have to deal with some user provided input. A common way is to display a form, so that the user can input the data. This data then has to be validated and some feedback should be provided, incase the data is invalid. To achieve this, we validate the data and then display feedback like an error message or multiple messages.

In this post, we will write a small validation library and leverage TypeScript to improve the developer experience.

Our validation library should be framework independent and only take care of validating data, leaving the actual UI representation in user land.

Let's assume that we are provided with some user data object. This could be similar to the following example.

const fieldValues = {
  name: "Test User",
  level: 10,
  description: "Test Description"
};

There is a specification that we need to implement, which specifies that name should not be empty and description should have a minimum length of 10 characters.

What we also want to do is return a validation result object, which we can pass to a UI function and render the error messages incase the data has invalid fields. This might look something like the next example.

const result = {
  name: true,
  level: true,
  description: "Minimum of 10 characters required!"
};

The result shape might differ from case to case. Our validation library should have no assumptions on how the result is structured, except that we're returning an object. In this specific case we want a function that enables us to pass in a collection of validation rules and the previously defined fieldValues and get back a validation result containing either a true, when valid, or an error message string.

const result = validate(validationRules, fieldValues);

Now that we have a general idea of how our library should function from an API perspective, the next step is to implement that library and provide the needed functionality.

Implementation

Before we start implementing the validation library, let's recap on the form field values, which we defined as being represented as an object.
Our validate function should return all or a subset of the provided keys with their corresponding validation result. For our basic implementation, we assume that our field validation functions either return a true or an error message string.

type ValidationResult<T, U> = Partial<{ [Key in keyof T]: U }>;

The ValidationResult type is what our validate function will return. What's still missing is how the validation rules should be defined. To keep as much as possible in user land, our validate function accepts a list of rules. These rules expect the complete field object and then return a validation result containing the complete object or only a subset of it.
This is how we will define a rule.

type Validation<T, U> = (fields: T) => ValidationResult<T, U>;

Let's write one or two validation functions next.

const hasLength = <T>(len: number, input: string | Array<T>) =>
  input.length >= len;

Our hasLength function expects some string or an array and then checks if the provided input is larger or equals the provided minimum length. This newly created predicate function can be a basic building for writing some validation functions.

const hasUserName = (input: string) =>
  hasLength(1, input) ? true : "Name is required.";
const hasValidDescription = (input: string) =>
  hasLength(10, input)
    ? true
    : "Description requires a minimum of 10 characters.";

Next we could define a collection of validation functions to run against some provided input.

const fieldValues = {
  name: "Test User",
  level: 10,
  description: "Test Description"
};

type FieldValues = typeof fieldValues;

/*
type FieldValues = {
    name: string;
    level: number;
    description: string;
}
*/

Our validate library should be able to accept and handle a collection of rules and some input and run these validations against the provided input and return a user land defined result shape.

const validationRules = [
  ({ name }: FieldValues) => ({
    name: hasUserName(name)
  }),
  ({ description }: FieldValues) => ({
    description: hasValidDescription(description)
  })
];

Now that we defined our validation rules we want to run them against our validate function. Technically we want to iterate over all the predicate functions, collect the results and then merge them together into an object, as defined via the ValidationResult type. To recall this is how we defined this:

type ValidationResult<T, U> = Partial<{ [Key in keyof T]: U }>;

Our implementation can be a combination of map and reduce, where we map over the predicates and then merge them into an object.

const validate = <T, U = boolean | string>(
  validations: Validation<T, U>[],
  fields: T
): ValidationResult<T, U> =>
  validations
    .map(validation => validation(fields))
    .reduce((acc, a) => Object.assign(acc, a), {});

We could simplify the code a little more, by running the predicate function inside the reduce as well.

const validate = <T, U = boolean | string>(
  validations: Validation<T, U>[],
  fields: T
): ValidationResult<T, U> =>
  validations.reduce(
    (acc, validation) => Object.assign(acc, validation(fields)),
    {}
  );

Finally we can test this out with some data.

const fieldValues = {
  name: "Test User",
  level: 10,
  description: "Test Description"
};
type FieldValues = typeof fieldValues;

const validationRules = [
  ({ name }: FieldValues) => ({
    name: hasUserName(name)
  }),
  ({ description }: FieldValues) => ({
    description: hasValidDescription(description)
  })
];

validate(validationRules, fieldValues); // {name: true, description: true}

In the above example both fields are valid, now let's test this with some invalid data and check the result.

const fieldValues = {
  name: "Test User",
  level: 10,
  description: "Test"
};

validate(validationRules, fieldValues);

/*
  {
    description: "Description requires a minimum of 10 characters.",
    name: true
  };
*/

The provided description didn't match the expected rule and our validate functionality correctly returned the pre-defined error message.


After going through this short lesson, we should have a good idea how to leverage TypeScript when building small libraries, especially when thinking about the shape of the in/out data.


If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Posted on by:

busypeoples profile

A. Sharif

@busypeoples

Focusing on quality. Software Development. Product Management. https://twitter.com/sharifsbeat

Discussion

markdown guide