This is part 6 of the series “Type Gymnastic”. If you haven’t already, go check out part 5. Let’s continue with more challenges today!
Table of contents
Open Table of contents
Intro
In this series of type challenges we do a walk through of all challenges that I used in my advent calender. We will continue from where we left off - challenge 15.
U Can’t Type This 🤸
We are in the stage of this series where the challenges will get harder in difficulty, new concepts and tricks will get introduced. Let’s go through them now.
Challenge 15
You are given the type
type UnionToIntersection<U> = any;
and you want to construct a type that accepts a generic and returns an intersection of the generic if it is a union type. An example would be:
type A = {
a1: string;
};
type B = {
b1: number;
};
type Example = UnionToIntersection<A | B>; // Example = {a1: string} & {b1: number}
A union i.e. type Union = A | B
is a type that can one of several types. A common use case for unions is discriminating unions where you use unions to narrow down the possible current type. Discriminating unions have a common property to help narrow down the possible type, for example:
type A = {
action: "save";
payload: string;
};
type B = {
action: "delete";
id: number;
};
type C = {
action: "pause";
};
type U = A | B | C;
function func(s: U) {
switch (s.action) {
case "save":
return `Saving ${s.payload}`;
case "delete":
return `Deleting ${s.id}`;
case "pause":
return "Pausing";
}
}
Intersection types combines multiple types into one by using the &
operator. This allows you to use existing types and turn them into one singular type. Which is exactly what we need to do in order to solve this challenge.
The solution to this is kind of tricky: we have to know about distributive conditional types, inference, contra-variance.
Imagine having a type like this:
type A<T> = T extends K ? X : Y;
and when you pass a union to the generic what actually happens is:
// Example type, if the generic T extends a type K return X else Y.
type A<T> = T extends K ? X : Y;
// We pass P and L union type as the generic
type B = A<P | L>
// What actually happens is distributed conditional types
(P extends T ? X : Y) | (L extends T ? X : Y)
We can start by adding a conditional type to our UnionToIntersection
type:
type UnionToIntersection<U> = U extends any ? U : never;
U extends any
should be true, if not we will return never
. If we were to use this type now with a union type:
type UnionToIntersection<U> = U extends any ? U : never;
type Example = UnionToIntersection<{ a: string } | { b: number }>;
// Example = {a: string} | {b: number}
As seen above, we just return the same type as we passed in. We have to manipulate U
somehow. What we can try to do instead of returning U
is to use the type U
as a function parameter type:
type UnionToIntersection<U> = U extends any ? (x: U) => any : never;
Now this doesn’t do much by itself. What we are doing now is just wrapping the generic type in a function. However, if we unwrap the function in an additional conditional type where we infer the function parameter we can bring the type into a contravariant position. Contravariance will force an intersection type to be inferred which solves the challenge of converting unions to intersections. But what is contravariance?
See the code examples below:
// COVARIANCE //
declare let a1: string;
declare let a2: string | number;
a2 = a1;
// This is allowed
This is allowed because a1: string
is part of the union a2: string | number
.
// CONTRAVARIANCE //
declare let f1: (arg: string) => void;
declare let f2: (arg: string | number) => void;
f2 = f1;
// This is NOT allowed!
This is not allowed because it basically breaks the function contracts. If f2 = f1
was allowed, then it would no longer be allowed to pass number
as an argument to the function.
What happens with parameter types that are put in contravariant positions inside conditional types is that all the distributed types are intersected - which is very helpful for solving this challenge:
type UnionToIntersection<U> = (U extends any ? (x: U) => any : never) extends (
x: infer R
) => any
? R
: never;
Which is the solution to this challenge!
Challenge 16
You are given the type
type Getter<T> = any;
and you want to construct a type that takes a generic and returns the properties as functions, prefix the names with ‘get’ and let the return type of the function be the initial property’s type, but remove the properties if they already are functions or if they are prefixed with ’$‘. An example would be:
type A1 = {
test: string;
};
type Example1 = Getter<A1>;
// Example = {getTest: () => string}
type A2 = {
val: string;
num: number;
$val: any;
fn: () => string;
$fn: () => string;
};
type Example2 = Getter<A2>;
// Example2 = {getVal: () => string; getNum: () => number;}
For this challenge we have to use mapped types to iterate over all properties, conditional types to see if the current property is of a specific type, and key remapping in order to change the property’s key. Let’s start with the mapped type:
type Getter<T> = {
[P in keyof T]: T[P];
};
This should be familiar to you if you have looked at previous posts in this series.
Since we want to change the types to be functions let’s do that instead of T[P]
:
type Getter<T> = {
[P in keyof T]: () => T[P];
};
Using the current type now will give us:
type A = {
val: string;
};
type Example = Getter<A>;
// Example = {val: () => string}
However we don’t check for any of the edge cases like if the property is already a function, or if the property’s key is prefixed with ’$‘. First let us do the key remapping to add the prefix ‘get’ to the property’s key:
type Getter<T> = {
[P in keyof T as `get${Capitalize<P & string>}`]: () => T[P];
};
Capitalize
is an intrinsic utility type from Typescript (explained in part 5) that takes a string and capitalizes the first letter. We need to add P & string
or else Typescript will complain that P
does not satisfy the constraint ‘string’. However, intersecting a string together with the key forces the key to be of a string type.
We want to filter out all properties that are prefixed with ’$‘. To do so, we can continue with the key remapping and use the built in utility type Exclude
. Exclude is a simple and powerful type that looks like this:
type Exclude<T, U> = T extends U ? never : T;
type A1 = Exclude<"a" | "b" | "c", "a">;
// A1 = "b" | "c"
What we are trying to use exclude for is to look for property keys in the mapped type that has the prefix ’$‘:
type Getter<T> = {
[P in keyof T as `get${Capitalize<
Exclude<P, P extends `$${infer _}` ? P : never> & string
>}`]: () => T[P];
};
Lastly, we want to remove the properties that already are functions. If we set the property key as never it will get ignored, so we can check if T[P]
extends a function and if it does we set the key to never:
type Getter<T> = {
[P in keyof T as T[P] extends (...args: any[]) => any
? never
: `get${Capitalize<
Exclude<P, P extends `$${infer _}` ? P : never> & string
>}`]: () => T[P];
};
Which is the solution to this challenge!
Challenge 17
You are given the type
type Permutations = any;
and you want to construct a type that accepts a generic union type and returns all the possible permutation represented in arrays. An example would be:
type Example = Permutations<"A" | "B">;
// Example = ['A', 'B'] | ['B', 'A']
It helps to have an understanding of distributed types in order to solve this challenge. We can start by adding a conditional type to distribute the generic union type:
type Permutations<T> = T extends unknown ? [T] : never;
When using this type now, we would get:
type A = Permutations<"A" | "B">; // A = ["A"] | ["B"]
Now we want to add the rest of the possible permutations to [T]
. We can add an additional generic parameter to the type to “store” the original union type
type Permutations<T, C = T> = T extends unknown ? [T] : never;
It’s helpful to think of the distributed types as a for loop. If the generic is T = "A" | "B" | "C"
, then inside the truthy section of the conditional T extends unknown
, we can imagine that T
will at first iteration equal "A"
then "B"
then "C"
and so on.
To add the rest of the permutations to [T]
we can recursively call the type, but exclude from C
(the original union). Keep in mind this is distributing over the unions of T
:
type Permutations<T, C> = T extends unknown
? [T, ...Permutations<Exclude<C, T>>]
: never;
Exclude<C, T>
will look like Exclude<"A" | "B" | "C", "A">
at first and exclude “A” from the union.
If we were to use this now we would see that it always will be equal to never
. Let’s add a conditional type to see if T
extends never
:
type Permutations<T, C = T> = [T] extends [never]
? []
: T extends unknown
? [T, ...Permutations<Exclude<C, T>>]
: never;
[T] extends [never]
is a trick to use with the never
type. If you try to use this type:
type T0<T> = T extends never ? "I'm never" : "I'm not never";
type T1 = T0<never>;
// T1 = never
You can see that T1
will default to the never
type. This is because conditional types distribute over unions, and never
is an empty union. Since there are no members in this union the result will be never
. However, if you put them in a tuple:
type T0<T> = [T] extends [never] ? "I'm never" : "I'm not never";
type T1 = T0<never>;
// T1 = "I'm never"
The type equals to the truthy value of the conditional!
So the solution to this challenge is the one mentioned above:
type Permutations<T, C = T> = [T] extends [never]
? []
: T extends unknown
? [T, ...Permutations<Exclude<C, T>>]
: never;
Part 7 coming soon. Part 7