Cracking TypeScript: Key Concepts to Tackle Type Challenges
Master TypeScript’s type system with type challenges. Learn key concepts like conditional types, recursion, and mapped types through examples, and reinforce understanding with hands-on practice.
Whether you want to test your knowledge or become proficient with the TypeScript’s type system, you should definitely try solving the TypeScript type challenges. But before you do, I recommend that you go over these concepts with which you must be familiar to successfully tackle the the challenges. While some of them are trivial, many of you will find that some of the challenges aren’t that easy if you haven’t attempted them before.
In this article, I tried to keep explanations short and focus on using examples to demonstrate the concepts. I assume you have some knowledge of the TypeScript’s type system but if you don’t and require more detail, there are many useful websites you can visit first, like the official docs and learntypescript.dev.
Conditional type
You can have conditional types in TypeScript which are powerful constructs. It’s in the form of A extends B ? X : Y
which basically means if A is of type B, then assign X, else assign Y. It’s important that you become familiar with this expression because it’s used everywhere. There will be many examples that use this expression in this post.
type StringIfNumber<T> = T extends number ? string : number;
type StringType = StringIfNumber<number>;
// The statement will evaluate to:
type StringType = string;
type NumberType = StringIfNumber<boolean>;
// The statement will evaluate to:
type NumberType = number;
Readonly array (tuple)
In many programming languages such as Python, a tuple is a data structure that is similar to a list, but one that is immutable. That is, once defined, it cannot be changed. In TypeScript, we don’t technically have a tuple type, but you can create a “tuple” with an array by making it readonly using the const
keyword.
// tuple becomes a readonly-array because of the `const` keyword.
const tuple = [0, 1, 2] as const;
// Suppose we have this. This expects T to be of an array type.
type List<T extends any[]> = T;
// Shows error because `tuple` is a readonly-array.
type A = List<typeof tuple>; // error
// As such, we need to specify `readonly` to accept tuple, which is a readonly-array.
type List<T extends readonly any[]> = T;
// No error.
type A = List<typeof tuple>;
// Also works because "normal" arrays are also accepted as readonly-arrays.
type B = List<[1, 2, 3]>;
type B = List<["hello", "world"]>;
Infer types
The infer
keyword is a crucial features of TypeScript which can be used to infer types. It’s important that you remember that you can only use it within conditional types (i.e. when using the extends
keyword.).
For example, let’s extract the resolved value’s type:
type AwaitedType<T> = T extends PromiseLike<infer R> ? R : T;
// `infer R` will infer R to be `number`.
type NumberType = AwaitedType<Promise<number>>;
// The statement will evaluate to:
type NumberType = number;
// `infer R` will infer R to be `string`.
type StringType = AwaitedType<Promise<string>>;
// The statement will evaluate to:
type StringType = string;
As another example, get return type of a function:
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// `infer R` will infer R to be `string`.
type StringType = GetReturnType<() => "hello, world">;
// The statement will evaluate to:
type StringType = string;
You can also use the rest operator with infer:
type RestValues<T> = T extends [any, ...infer Rest] ? Rest : never;
type X = RestValues<[1, 2, 3, 4]>;
// The statement will evaluate to:
type X = [2, 3, 4];
Additionally, infer variadic parameter types:
type GetArgs<T extends (...args: any[]) => any> = T extends (
...any: infer Args
) => any
? Args
: never;
type X = GetArgs<(a: string, b: number) => void>;
// The statement will evaluate to:
type X = [string, number];
// As a side note, you can convert [string, number] into a union type:
type Y = X[number];
// The statement will evaluate to:
type y = string | number;
You can also use multiple infers:
type GetFirstOrRest<T extends any[], P extends boolean> = T extends [
infer First,
...infer Rest
]
? P extends true
? First
: Rest
: never;
type X = GetFirstOrRest<["one", "two", "three"], true>;
// The statement will evaluate to:
type X = "one";
type Y = GetFirstOrRest<["one", "two", "three"], false>;
// The statement will evaluate to:
type Y = ["two", "three"];
There will be more examples of the usage of the infer
keyword in the subsequent sections because it’s an essential feature of TypeScript.
Distributive conditional union type
When conditional types operate on a generic type, they exhibit distributive behavior when applied to a union type:
// Suppose we have `ToArray` that accepts a generic type `T`.
type ToArray<T> = T extends any ? T[] : never;
// Given a union type `string | number`,
type A = ToArray<string | number>;
// The statement will evaluate to:
type A = string[] | number[];
// The idea is something like:
// for T in [string | number]:
// return T[]
Another example:
type Subtract<A, B> = A extends B ? never : A;
type StringNull = Subtract<string | number | boolean | null, number | boolean>;
// The statement will evaluate to:
type StringNull = string | null;
// The idea is something like:
// for T in [string | number | boolean | null]:
// if T in [number | boolean]:
// return never
// return T
Recursion
You can do recursion with types! If you’re familiar with the concept of recursion, it should be easy to apply to TypeScript types.
// Suppose we have nested promises, and you want to extract the resolved value.
type A = Promise<Promise<string>>;
type B = Promise<Promise<Promise<boolean>>>;
// AwaitedType<T> will recursively call itself to unwrap the resolved type.
type AwaitedType<T> = T extends PromiseLike<infer R> ? AwaitedType<R> : T;
type X = AwaitedType<A>; // string
type Y = AwaitedType<B>; // boolean
Spread operator
Did you know that you can apply the spread operator on types?
type Concat<A extends readonly any[], B extends readonly any[]> = [...A, ...B];
type X = Concat<[1, 2, 3], [4, 5, 6]>;
// The statement will evaluate to:
type X = [1, 2, 3, 4, 5, 6];
Rest operator
You can also use the rest operator on types.
type FirstArgument<T extends Function> = T extends (
f: infer F,
...args: any[]
) => any
? F
: never;
type X = FirstArgument<(a: string, b: number) => void>;
// The statement will evaluate to:
type X = string;
Similarly:
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type X = First<[number, string, boolean]>;
// The statement will evaluate to:
type X = number;
You can also use rest operator with infer
, as shown in the Infer types
section:
type Rest<T extends any[]> = T extends [any, ...infer F] ? F : never;
type X = Rest<[number, string, boolean]>;
// The statement will evaluate to:
type X = [string, boolean];
Check object type
There are cases where you want to check if the type is an object type, like {alias: 'Justin'}
or ['Hyun', 'Wook', 'Kim']
. Types that are not of object type are string
, number
, boolean
, null
, undefined
, and unknown
.
type IsObject<T> = T extends object ? true : false;
type X = IsObject<{ hello: "world" }>; // true
type Y = IsObject<["hello", "world"]>; // true
type A = IsObject<string>; // false
type B = IsObject<number>; // false
type C = IsObject<boolean>; // false
type D = IsObject<null>; // false
type E = IsObject<undefined>; // false
type F = IsObject<unknown>; // false
// Special cases
type G = IsObject<never>; // never
type H = IsObject<any>; // boolean because "any" can represent both an object and non-object
Values of an array
There are two ways to get the values of an array as a union type.
// Method 1:
type ArrayValues<T extends readonly any[]> = T[number];
// Method 2:
type ArrayValues<T extends readonly any[]> = T extends (infer R)[] ? R : never;
type X = ArrayValues<["hello", "world"]>;
// The statement will evaluate to:
type X = "hello" | "world";
Mapped types
Another essential type is the mapped type. You iterate through the keys of an object and assign the type of the value associated with the key.
type MappedType<T> = {
[K in keyof T]: T[K];
};
const user = {
name: "Hyun Wook Kim",
alias: "Justin",
languages: ["Korean", "English"],
};
type X = MappedType<typeof user>;
// The statement will evaluate to:
type X = {
name: string;
alias: string;
languages: string[];
};
You can also make modifications to use the keys you define:
type NameAliasOnly<T, P extends keyof T> = {
[K in P]: T[K];
};
const user = {
name: "Hyun Wook Kim",
alias: "Justin",
languages: ["Korean", "English"],
};
type X = NameAliasOnly<typeof user, "name" | "alias">;
// The statement will evaluate to:
type X = {
name: string;
alias: string;
};
Access Properties
You can access properties of an object by indexing its key.
type GetName<T extends { name: string }> = T["name"];
type X = GetName<{ name: "Charlie" }>;
// The statement will evaluate to:
type X = "Charlie";
// However in the following case, you get a `string`:
const user = { name: "Johnson" };
type Y = GetName<typeof user>;
// The statement will evaluate to:
type Y = string;
// If you want the value, you should use the `const` keyword:
const user = { name: "Johnson" } as const;
type Z = GetName<typeof user>;
// The statement will evaluate to:
type Z = "Johnson";
keyof any
This expression returns the type of any value that can be used as an index to an object, which are string
, number
, and symbol
.
type IndexTypes = keyof any;
// The statement will evaluate to:
type IndexTypes = string | number | symbol;
Go try it out
You will find it difficult to master these concepts without solving any problems. A good approach is to go try out the challenges, and come back to revise the concepts when you get stuck. This hands-on, trial-and-error method will only reinforce your understanding. So, go ahead, immerse yourself in the challenges!