when trying to create tools with typescript, especially aiming for type safety and flexibility, it is common that we end up with many generic types,
some of it even end up with a big chunk of type manipulation logic.
So how can we be confident that our types are working, how can we test our types?
Turn out it is simple and also hard, but we will focus on the simple part first.
let's take string literal type substring counting as our test subject
type GetCountOfSubString<
String_ extends string,
SubString extends string,
Count extends unknown[] = []
> = String_ extends `${string}${SubString}${infer Tail}`
? GetCountOfSubString<Tail, SubString, [1, ...Count]>
: Count['length']
type NumberOfA = GetCountOfSubString<"a--a--aa--a","a"> // 5
We want to make sure GetCountOfSubString<"a--a--aa--a","a">
always result in 5
basically both should extend each other
next, we create the checker, the checker consist of 2 parts
first is Expect, we want to check whether both types extends each other
type Expect<T, U>= T extends U ? U extends T ? true : false : false
type r1 = Expect<GetCountOfSubString<"a--a--aa--a","a">,5> // true, success check
type r2 = Expect<GetCountOfSubString<"a--a--aa--a","a">,1> // false, fail check
so far so good, you get the result you want, the type is true if the result is correct and false if the result is incorrect
but something is missing, when you run type-check with tsc,
nothing happens, this is because it simply returns the type as true and false, and nothing is invalid about it, so typescript does not complain.
so we need 2nd part, the assertion
type Assert<T extends true> = T // be anything after '=', doesn't matter
applying them
type Expect<T, U>= T extends U ? U extends T ? true : false : false
type Assert<T extends true> = T // be anything after '=', doesn't matter
type r1 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,5>> // true, pass test
type r2 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,1>> // false, fail test
now we see that the fail test failed, and when we run tsc, we can see the error in the console.
but wait something is still not right, what is it?
well, a fail test should fail, that is expected, and should not trigger an error
so are we back to square one?
no, we are closer, here is how we solve it, by using the @ts-expect-error
comment
type r1 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,5>> // true, pass test
// @ts-expect-error
type r2 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,1>> // false, fail test
there, no more type-checking error
@ts-expect-error
only suppress the error if the line has an error, else if you use it on a perfectly ok line, TS will give us an error instead, and this is the behaviour that we want
so let's see if there is a bug in GetCountOfSubString, will this works as expected?
let's try to fail our pass test:
type GetCountOfSubString<
String_ extends string,
SubString extends string,
Count extends unknown[] = []
> = "BUG!!"
type Expect<T, U>= T extends U ? U extends T ? true : false : false
type Assert<T extends true> = T // be anything after '=', doesn't matter
type r1 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,5>> // true, pass test
// @ts-expect-error
type r2 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,1>> // false, fail test
let's try to fail our fail test:
type GetCountOfSubString<
String extends string,
SubString extends string,
Count extends unknown[] = []
> = 1
type Expect<T, U>= T extends U ? U extends T ? true : false : false
type Assert<T extends true> = T // be anything after '=', doesn't matter
type r1 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,5>> // true, pass test
// @ts-expect-error
type r2 = Assert<Expect<GetCountOfSubString<"a--a--aa--a","a">,1>> // false, fail test
yup, it works!
but we are not done yet, if you are using linter like eslint, it will complains the type is declared but never used
there are 2 ways to solve it:
or we turn Assert into a function instead
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const assert = <T extends true>() => {
//
}
assert<Expect<GetCountOfSubString<'a--a--aa--a', 'a'>, 5>>() // true, pass test
// @ts-expect-error
assert<Expect<GetCountOfSubString<'a--a--aa--a', 'a'>, 1>>() // false, fail test
the second method is recommended, it is shorter because it doesn't require us to create a new type for every assertion
that is it for part 1, in part 2 we will take care of some edge cases, which is the hard part
Top comments (4)
Interesting, but how would you embed it into a jest/mocha/whatever the framework test case?
The only way I see is somehow modifying the assert function to return a value which can be then checked in the framework's
expect
block, but how to do it? Could go withand later with
in jest or whatever, but then the intellisense of the argument defeats the check purpose, because you can't get an failed test :/
interesting, I am not aware of this is possible, do you have link to the source?
I am not aware if it is possible too, we could 'force' to pass proper boolean based on what assert generic returns, like this. Though it looks like a dirty hack :)
if this is possible, I think maybe they use typescript compiler api
I am not familiar with the compiler api, it is quite tedious to be honest and is not properly documented