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
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
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}%`;
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";
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
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
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
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) => { }
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]
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
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
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
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};`
| ...
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>]>
)
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}`
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
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
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)