A simple validation model, leveraging discriminated unions

September 2020 | Permalink

Illustration by Barbara

Validation is a common part of web applications. Different patterns for validation apply at different application layers (ie: input, data model, network schemas) and types (ie: data, ranges or constraints, structure) with a common goal: making sure the data we deal with is correct and useful.

In this article I will be exploring a simple pattern for data model validation in Typescript and how discrimnated unions can help structuring a validation object.

The goal

Let's start from what we are trying to achieve: a pattern for validating data and a consumable feedback object:

export function validate(data: string): ValidationResult {
    if (data.length > 3) return { result: true };
    return { 
        result: false,
        error: 'String must be at least 3 characters long'
    }
}

In this scenario, all validation results must adhere to the ValidationResult interface. The resulting feedback object will contain the result of the computation as well as errors, if any.

A naive approach

We might want to start by typing our validation object, which could look like the one below:

export type ValidationResult = {
    result: boolean;
    error?: string;
};

The intent with the above is to cater for both successful and unsuccessful validations. Upon an unsuccessful validation, we can provide an error message to be used by the consumer.

A successful validation flow would be similar to the one below:

const validation = validate('hello world');
if (validation.result) {
    // success, let's proceed   
}

Neat. Let's have a look at an unsuccessful validation result:

const validation = validate('hi');
if (!validation.result) {
    log(validation.error);
}

The compiler here might complain that the log function is being fed a value that may be possibly undefined (assumes strictNullChecks is used).

One way around it, which I would not recommend (it defeats the purpose of having a static type environment), is the non-null assertion operator !:

if (!validation.result) {
    log(validation.error!); // all good, thanks to a bang!
}

Another way around it is using a type-guard, a function that declares a type predicate:

export function isSuccessfulValidationResult(
    validation: ValidationResult
): validation is SuccessfulValidationResult {
    return validation.result === true;
}

export function isUnsuccessfulValidationResult(
    validation: ValidationResult
): validation is UnsuccessfulValidationResult {
    return validation.result === false;
}

A type-guard check will guarantee the scope we are expecting:

if (isUnsuccessfulValidationResult(validationResult)) {
    // validationResult is UnsuccessfulValidationResult
    // error is defined
    log(validationResult.error); 
}

A better way of solving the issue is looking at types and assessing whether narrowing down types, so that the compiler can be made aware of successful and unsuccessful contexts, is a viable solution.

Discriminated Unions to the rescue

There is indeed a way to instruct the compiler about the different contexts: Discriminated Unions and a stricter type for the result.

Consider the type below:

export type SuccessfulValidationResult = { 
    result: true; 
}

export type UnsuccessfulValidationResult = { 
    result: false; 
    error: string; 
}

export type ValidationResult = SuccessfulValidationResult | 
UnsuccessfulValidationResult;

Note that the result types have been narrowed from boolean to true and false. Two distinct validation result types will require us to discriminate the types in our validation function while keeping the ValidationResult contract:

function validate(data: string): ValidationResult {
    if (data.length > 3) 
        return { result: true } as SuccessfulValidationResult;

    return { 
        result: false,
        error: 'String must be at least 3 characters long'
    } as UnsuccessfulValidationResult;
}

The narrowed boolean types and the discriminated unions allow the compiler to provide the correct context depending on the code flow:

if (validation.result) {
    // context is SuccessfulValidationResult
}

if (!validation.result) {
    // context is UnsuccessfulValidationResult,
    // error is a defined property, therefore:
    log(validation.error);
}

Useful resources