DEV Community

yossarian
yossarian

Posted on

TypeScript static validation

I assume you are familiar with TypeScript mapped types and type inference.

In this article I will try to show you the power of static validation in TypeScript.

Validation of inferred function arguments

Let's start from a small example to better understand the approach. Imagine we have a function which expects some css width value. It may be 100px, 50vh or 10ch. Our function should do anything with argument, because we are not interested in business logic.
The naive approach would be to write this:

const units = (value: string) => { }

units('hello!') // no error
Enter fullscreen mode Exit fullscreen mode

Ofcourse, this is not what we want. Our function should allow only valid css value, it means that the argument should match the pattern ${number}${unit}. Which in turn means that we need to create extra types. Let's try another one approach, more advanced:

type CssUnits = 'px' | 'vh' | '%'

const units = (value: `${number}${CssUnits}`) => { }

units('20px') // ok
units('40') // error
units('40pxx') // error
Enter fullscreen mode Exit fullscreen mode

Above solution looks good. Sorry, I'm not an expert in CSS units, this is all that I know :). Please be aware that unions inside template literal strings are distributive. It means that both CssValue0 and CssValue1 are equal. More about distributive types you can find here.

type CssValue0 = `${number}${CssUnits}`;
type CssValue1 = `${number}px` | `${number}vh` | `${number}%`; 
Enter fullscreen mode Exit fullscreen mode

Now we can extend our requirements. What if we are no longer allowed to use % units. Let me clarify. We are allowed to use all other css units. So you should treat this rule as a negation. Please be aware that there is no negation operator in typescript. For instance we are not allowed to declare a standalone type where Data might be any type but not an "px".

type Data = not "px";
Enter fullscreen mode Exit fullscreen mode

However, we can emulate this with help of inference on function arguments.

type CssUnits = 'px' | 'vh' | '%'

type CssValue = `${number}${CssUnits}`

type ForbidPx<T extends CssValue> = T extends `${number}px` ? never : T

const units = <Value extends CssValue>(value: ForbidPx<Value>) => { }

units('40%') // ok
units('40vh') // ok
units('40px') // error
Enter fullscreen mode Exit fullscreen mode

As you might have noticed, there were intoroduced several important changes. First of all, I have created CssValue type which represents our css value. Second, I have added Value generic argument in order to infer provided argument. Third, I have added ForbidPx utility type which checks if a provided generic argument contains px. If you are struggling to understand template literal syntax, please check docs.

ForbidPx might be represented through this js code:

const IsRound = (str: string) => str.endsWith('px') ? null : str
Enter fullscreen mode Exit fullscreen mode

Our types are still readable - it means we have not finished yet :). What would you say if we will add another one rule ? Let's say our client wants us to use only round numbers, like 100, 50, 10 and not 132, 99, 54. Not a problem.

type CssUnits = 'px' | 'vh' | '%'

type CssValue = `${number}${CssUnits}`

type ForbidPx<T extends CssValue> = T extends `${number}px` ? never : T
type IsRound<T extends CssValue> = T extends `${number}0${CssUnits}` ? T : never;


const units = <Value extends CssValue>(value: ForbidPx<Value> & IsRound<Value>) => { }

units('40%') // ok
units('401vh') // error, because we are allowed to use only rounded numbers
units('40px') // error, because px is forbidden
Enter fullscreen mode Exit fullscreen mode

IsRound checks if there is 0 between the first part of css value and the last part (CssUnits). If there is 0, this utility type returns never, otherwise it returns the provided argument.

You just intersect two filters and it is done. For the sake of brevity, let's get rid of all our validators and go back to our original implementation.

type CssUnits = 'px' | 'vh' | '%'

type CssValue = `${number}${CssUnits}`

const units = <Value extends CssValue>(value: Value) => { }

Enter fullscreen mode Exit fullscreen mode

Here is our new requirement. We should allow only numbers in range from 0 to 100. This requirement is a tricky one, because TS does not support any range formats of number types. However, TypeScript does support recursion. It means that we can create a union of numbers. For instance 0 | 1 | 2 | 3 .. 100. Before we do that, I will show you JavaScript representation of our algorithm:

const range = (N: number, Result: 0[] = []): 0[] => {
  if (N === Result.length) {
    return Result
  }

  return range(N, [...Result, Result.length])
}
console.log(range(5)) // [0, 0, 0, 0, 0] 
Enter fullscreen mode Exit fullscreen mode

I'd be willing to bet that this code is readable enough and self explanatory. Until length of Result is less than N we call range recursively with extra zero.

Let's see our implementation.

type CssUnits = 'px' | 'vh' | '%'

type CssValue = `${number}${CssUnits}`

type MAXIMUM_ALLOWED_BOUNDARY = 101

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    /**
     * Check if length of Result is equal to N
     */
    (Result['length'] extends N
        /**
         * If it is equal to N - return Result
         */
        ? Result
        /**
         * Otherwise call ComputeRange recursively with updated version of Result
         */
        : ComputeRange<N, [...Result, Result['length']]>
    )

type NumberRange = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY>[number]

type IsInRange<T extends CssValue> =
    /**
     * If T extends CssValue type
     */
    T extends `${infer Num}${CssUnits}`
    /**
     * and Num extends stringified union of NumberRange
     */
    ? Num extends `${NumberRange}`
    /**
     * allow using T
     */
    ? T
    /**
     * otherwise - return never
     */
    : never
    : never

const units = <Value extends CssValue>(value: IsInRange<Value>) => { }

units('100px')
units('101px') // expected error
Enter fullscreen mode Exit fullscreen mode

Implementation of ComputeRange is pretty straightforward. The only limit - is TypeScript internal limits of recursion.

Maximum value of MAXIMUM_ALLOWED_BOUNDARY which is supported by TypeScript is - 999. It means that we can create a function which can validate RGB color format or IP address.

Because this article is published on css-tricks.com, I think it will be fair to validate RGB.

So, imagine you have a function which expects three arguments R, G and B accordingly.

type MAXIMUM_ALLOWED_BOUNDARY = 256

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    (Result['length'] extends N
        ? Result
        : ComputeRange<N, [...Result, Result['length']]>
    )

type U8 = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY>[number]

const rgb = (r: U8, g: U8, b: U8) => { }

rgb(0, 23, 255) // ok
rgb(256, 23, 255) // expected error, 256 is highlighted

Enter fullscreen mode Exit fullscreen mode

Repetitive patterns

Sometimes we need a type which represents some repetitive patterns. For instance we have this string "1,2; 23,67; 78,9;". You probably have noticed that there is a pattern ${number}, ${number};. But how can we represent it in a TypeScript type system? There are two options. We either create a dummy function only for inference and validation purposes or standalone type.
Let's start with a dummy function. Why am I saying that function is dummy ? Because the only purpose of this function is to make static validation of our argument. This function does nothing at runtime, it just exists.

type Pattern = `${number}, ${number};`

type IsValid<Str extends string, Original = Str> =

    Str extends `${number},${number};${infer Rest}`
    ? IsValid<Rest, Original>
    : Str extends '' ? Original : never

const pattern = <Str extends string>(str: IsValid<Str>) => str

pattern('2,2;1,1;') // ok
pattern('2,2;1,1;;') // expected error, double semicolon ath the end

pattern('2,2;1,1;0,0') // expected error, no semicolon ath the end
Enter fullscreen mode Exit fullscreen mode

While this function works, it has its own drawbacks. Every time we need a data structure with a repetitive pattern we should use an empty function just for the sake of static validation. Sometimes it is handy, but not everybody likes it.

However, we can do better. We can create a union with allowed variations of states.
Consider this example:

type Coordinates = `${number},${number};`;

type Result =
    | `${number},${number};`
    | `${number},${number};${number},${number};`
    | `${number},${number};${number},${number};${number},${number};`
    | ...
Enter fullscreen mode Exit fullscreen mode

In order to do this, we should slightly modify ComputeRange utility type.

type Repeat<
    N extends number,
    Result extends Array<unknown> = [Coordinates],
    > =
    (Result['length'] extends N
        ? Result
        : Repeat<N, [...Result, ConcatPrevious<Result>]>
    )
Enter fullscreen mode Exit fullscreen mode

As you might have noticed, I have added ConcatPrevious and did not provide implementation of this type by purpose. Just want to make this mess more readable. So, in fact, we are using the same algorithm with extra callback - ConcatPrevious. How do you think we should implement ConcatPrevious ? It should receive the current list and return the last element + new element. Something like this:

const ConcatPrevious = (list: string[]) => `${list[list.length-1]}${elem}`
Enter fullscreen mode Exit fullscreen mode

Nothing complicated right? Let's do it in type scope.

type Coordinates = `${number},${number};`;

/**
 * Infer (return) last element in the list
 */
type Last<T extends string[]> =
    T extends [...infer _, infer Last]
    ? Last
    : never;

/**
 * Merge last element of the list with Coordinates
 */
type ConcatPrevious<T extends any[]> =
    Last<T> extends string
    ? `${Last<T>}${Coordinates}`
    : never
Enter fullscreen mode Exit fullscreen mode

Now, when we have our utility types, we can write whole type:

type MAXIMUM_ALLOWED_BOUNDARY = 10

type Coordinates = `${number},${number};`;

type Last<T extends string[]> =
    T extends [...infer _, infer Last]
    ? Last
    : never;

type ConcatPrevious<T extends any[]> =
    Last<T> extends string
    ? `${Last<T>}${Coordinates}`
    : never

type Repeat<
    N extends number,
    Result extends Array<unknown> = [Coordinates],
    > =
    (Result['length'] extends N
        ? Result
        : Repeat<N, [...Result, ConcatPrevious<Result>]>
    )

type MyLocation = Repeat<MAXIMUM_ALLOWED_BOUNDARY>[number]

const myLocation1: MyLocation = '02,56;67,68;' // ok
const myLocation2: MyLocation = '45,56;67,68;1,2;3,4;5,6;7,8;9,10;' // ok
const myLocation3: MyLocation = '45,56;67,68;1,2;3,4;5,6;7,8;9,10,' // expected error no semicolon at the end
Enter fullscreen mode Exit fullscreen mode

Please be aware that MyLocation is not some kind of infinitely repeated pattern. It is just a union of the maximum allowed number of elements. Feel free to increase MAXIMUM_ALLOWED_BOUNDARY until TS will throw an error. I'd be willing to bet that it should be enough for most of the cases.

Top comments (0)