iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💭

Solving TypeScript type challenges

に公開

Let's Solve TypeScript Type Challenges

Nakano as a Service

This article is a direct copy of the Markdown slides written with Slidev below.

SpeakerDeck - Let's Solve TypeScript Type Challenges


Motivation

  • Our products are unified with TypeScript from the frontend to the backend
  • To improve code quality, it might be beneficial to know TypeScript deeply
  • While studying documentation theoretically is good, looking through it while solving problems is more practical

→ Let's do TypeScript type challenges


What is TypeScript Type Challenges?

  • A collection of problems where you implement utility types that assist with type definitions in TypeScript
  • Knowing utility types themselves leads to knowing what is possible in TypeScript
  • Furthermore, by implementing them yourself, you can gain a deeper understanding of TypeScript's language features

The problems are published in the following GitHub repository:

GitHub - TypeScript type challenges


Outline

  1. The origin of TypeScript
  2. Basics of TypeScript types
  3. Solving Beginner's TypeScript type challenges
    1. Explanation of utility types
    2. Solution explanation

The Origin of TypeScript

JavaScript has no types

function add(a, b) {
  return a + b;
}

add(5, "10");  // Output: "510"
add(5, [1, 2]);  // Output: "51,2"
add(5, null);  // Output: 5
add(5, undefined);  // Output: NaN
  • Causes unexpected behavior
  • Cannot know if it works correctly until execution
  • A hotbed for bugs

The Origin of TypeScript

Allowing types while keeping JavaScript syntax

function add(a: number, b: number): number {
  return a + b;
}

add(5, "10");  // Error: Argument of type '"10"' is not assignable to parameter of type 'number'.
add(5, [1, 2]);  // Error: Argument of type 'number[]' is not assignable to parameter of type 'number'.
add(5, null);  // Error: Argument of type 'null' is not assignable to parameter of type 'number'.
add(5, undefined);  // Error: Argument of type 'undefined' is not assignable to parameter of type 'number'.
  • Checks types at compile time and removes the type parts (called transpilation)
function add(a, b) {
  return a + b;
}
  • Can add types to existing JS code assets → It has that much flexibility

Entrance to Flexibility: Generic Types

What are Generic Types: Functions that take types as arguments and return types

Example: Wanting to declare an object type that contains a numeric type (Without Generic Types)

type NumberBox = {
    content: number
}

// Usage example
const box: NumberBox = {
  content: 1
}

Entrance to Flexibility: Generic Types

Example: Also wanting to prepare versions for string, date, and boolean types (Without Generic Types)

type NumberBox = {
    content: number
}

type StringBox = {
    content: string
}

type DateBox = {
    content: Date
}

type BooleanBox = {
    content: boolean
}
...

Doing this is a bit tedious → Use Generic Types


Entrance to Flexibility: Generic Types

type Box<T> = {
    content: T
}

type NumberBox = Box<number>
type StringBox = Box<string>
type DateBox = Box<Date>
type BooleanBox = Box<boolean>

// Usage example
const bbox: BooleanBox = { content: true }
const bbox: Box<boolean> = { content: true } // Can also be written directly

This <...> part is called a type argument.

  • Type arguments of generic types → like arguments in functions
  • Generic types are the type version of functions
    • Functions that take types as arguments and return types

Adding Type Constraints to Generic Type Arguments

Recap: Typing Function Arguments

function add(a: number, b: number): number {
  return a + b;
}

I want to do the same for generic types.


Type Constraints with extends

type Box<T extends number> = {
    content: T
}

type NumberBox = Box<number>
type StringBox = Box<string> // Type error
type DateBox = Box<Date> // Type error
type BooleanBox = Box<boolean> // Type error

Now, only number can be passed into T of Box<T>.

—The body craves strong typing—


Literal Types and Union Types

type Three = 3 // A type that only allows 3 (Literal Type)
type Four = 4 // A type that only allows 4 (Literal Type)
type ThreeOrFour = Three | Four // A type that allows only 3 or 4 (Union Type)

const x: ThreeOrFour = 3
const y: ThreeOrFour = 4
const z: ThreeOrFour = 334 // Type error

Using this, we can create a Box type that only accepts 3 or "four".

type ThreeOrFourBox<T extends 3 | "four"> = {
    content: T
}

type FourBox = ThreeOrFourBox<"four">
type FourBox = ThreeOrFourBox<5> // Type error

const x: FourBox = { content: "four" }
const x: FourBox = { content: 4 } // Type error

Summary So Far

  • TypeScript is a language that adds types to JavaScript.
  • Generic types <...> are functions that take types as arguments and return types.
  • Just like adding types to function arguments, we added type constraints to generic type arguments.
  • We created types that only allow specific values like 3 or "foo" using literal types.
  • We created types that allow 3 OR "foo" using union types |.
  • All that's left is practice.

Let's solve TypeScript type challenges!

Note: Problem statements have been partially modified for simplicity.


Beginner 1-Pick: Problem

Implement the built-in type utility Pick<T, K> without using it, creating a type that extracts properties from T that are in K.

For example:

type Todo = {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>

const todo: TodoPreview = {
    title: 'Clean room',
    completed: false,
}

Beginner 1-Pick: Explanation

First, let's consider the constraints on the type arguments.

type MyPick<T extends object, K extends keyof T> = { /* To be written later */ }

T extends object

A type constraint ensuring that the type argument T is an object type and not a string, number, etc.

keyof T: keyof type operator

type Book = {
  title: string;
  price: number;
  rating: number;
};
type BookKey = keyof Book;
// The above is equivalent to the following:
type BookKey = "title" | "price" | "rating";

Beginner 1-Pick: Explanation

Next, let's consider using K as the keys for the object type.

type MyPick<T extends object, K extends keyof T> = {
  [X in K]: X /* T is not used yet */
}

{ [X in K]: X }: Mapped Types

It assigns each type from the union type given to the type variable K into the type variable X, similar to a for loop, and returns an object type with X as the keys. Also, X can be used on the right side of the : corresponding to that key X.

type AB = {
  [X in "a" | "b"]: X
}
// This is equivalent to:
type AB = {
  a: "a",
  b: "b"
}

Beginner 1-Pick: Explanation

The completed version:

type MyPick<T extends object, K extends keyof T> = {
  [X in K]: T[X]
}

T[X]: Indexed Access Types

When accessing an object type or array type using an index of type X, it returns the type of the property or element that can be returned.

type Todo = { // Example of an object type
  title: string
  completed: boolean
}
type TitleOfTodo = Todo["title"] // becomes string
type TitleOfTodo = Todo[string] // becomes string | boolean

type Arr = boolean[] // Example of an array type
type TypeOfArr = Arr[334] // becomes boolean
type TypeOfArr = Arr[number] // also becomes boolean

Beginner 1-Pick: Explanation

The completed version:

type MyPick<T extends object, K extends keyof T> = {
  [X in K]: T[X]
}

Example: When the type {a:number, b:string, c:boolean} is assigned to T and the type "a" | "c" is assigned to K:

// Assigned only T
MyPick<{ a: number, b: string, c: boolean }, K extends "a" | "b" | "c">

// Also assigned K
MyPick<{ a: number, b: string, c: boolean }, "a" | "c">
 => {
    [X in "a" | "c"]: { a: number, b: string, c: boolean }[X]
  }
 => {
    "a": { a: number, b: string, c: boolean }["a"],
    "c": { a: number, b: string, c: boolean }["c"]
  }
 => { a: number, c: boolean }

layout: center

By the way, you can start solving these problems from the following link without any environment setup!

GitHub - TypeScript type challenges


Beginner 2-Readonly: Problem

Implement a type that makes all properties of T read-only without using the built-in type utility Readonly<T>. Properties of the implemented type cannot be reassigned.

For example:

type Todo = {
  title: string
  description: string
}

const todo: MyReadonly<Todo> = {
  title: "Hey",
  description: "foobar"
}

todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property

Beginner 2-Readonly: Explanation

The completed version:

type MyReadonly<T extends object> = {
  readonly [X in keyof T]: T[X]
}
  • You can add the readonly attribute to Mapped Types.

Example: Intermediate steps of type computation when the type { x: number, y: string } is given to T

MyReadonly<{ x : number, y: string }>
 => {
   readonly [X in "x" | "y"]: { x: number, y: string }[X]
 }
 => {
   readonly x: { x: number, y: string }["x"],
   readonly y: { x: number, y: string }["y"]
 }
 => {
   readonly x: number,
   readonly y: string
 }

Beginner 3-Tuple to Object: Problem

Implement a type that takes a tuple and converts it into an object type where each value of the tuple is used as both the key and the value.

For example:

type Tuple = ['tesla', 'model 3', 'model X', 'model Y']

type Result = TupleToObject<Tuple>

const r: Result = { 
    'tesla': 'tesla',
    'model 3': 'model 3',
    'model X': 'model X',
    'model Y': 'model Y'
}

Beginner 3-Tuple to Object: Explanation

What is a tuple type anyway?

  • An array type where the number of elements and the type of each element are fixed.

  • Examples of simple array types: number[], string[], "a"[], 1[]

  • Examples of tuple types: [number, string], ["a", 1, true]

  • A stricter version of an array type: [number, string] extends any[]

  • When you want to assert that an array in literal notation is a tuple type, you write [...] as const.

type T = ['tesla', 'model 3', 'model X', 'model Y']  // Declaration of a tuple type

const teslaArr: string[] = ['tesla', 'model 3', 'model X', 'model Y']
const teslaTuple: T = ['tesla', 'model 3', 'model X', 'model Y'] // This is an error (array in literal notation)
const teslaTuple: T = ['tesla', 'model 3', 'model X', 'model Y'] as const // Asserting that it is a tuple

Beginner 3-Tuple to Object: Explanation

Completed version

type TupleToObject<T extends any[]> = {
  [X in T[number]]: X
}

T[number]: Indexed Access Types for tuple T

  • Since tuple types, unlike array types, have defined types for each element, it returns those types.
  • Example: If T is ["a", 1, true]T[2] is type true, and T[number] is the union type "a" | 1 | true.
  • Example: If T is string[] → both T[100] and T[number] are type string.
TupleToObject<["a", "b", "c"]> = {
  [X in ["a", "b", "c"][number]]: X
}
 => {
    [X in "a" | "b" | "c"]: X
  }
 => { a: "a", b: "b", c: "c" }

Beginner 4-First of Array: Problem

Implement First<T> that takes an array T and returns the type of its first property.

For example:

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type head1 = First<arr1> // becomes 'a'
type head2 = First<arr2> // becomes 3
type head3 = First<[]> // becomes never

Beginner 4-First of Array: Explanation

Close but not quite:

type First<T extends any[]> = T[0]

We should be able to return the type at index 0 of the array (or tuple) using Indexed Access Types.

type head3 = First<[]> // We want this to be never, but it becomes undefined!

In this case, a never type is required, so we want to return the never type.

By the way, if you actually access an out-of-bounds index:

const emptyArr: [] = [] as const

console.log(emptyArr[0]) // undefined is output

Beginner 4-First of Array: Explanation

Completed version:

type First<T extends any[]> = T extends [] ? never : T[0]

T extends U ? X : Y: Conditional Types

  • A type-level conditional operator that returns X when the relationship T extends U is satisfied, and Y otherwise.
  • When extends is used outside of type arguments <...>, it is almost always Conditional Types.
type R1 = 1 extends number ? "YES!" : "NO!" // becomes "YES!" type
type R2 = "x" extends boolean ? true : false // becomes false type

// When [] is passed to T in T extends [] ? never : T[0]
type R3 = [] extends [] ? never : [][0] // becomes never type

Beginner 5-Length of Tuple: Problem

Implement a type Length<T> that takes a tuple T and returns its length.

For example:

type tesla = ['tesla', 'model 3', 'model X', 'model Y']
type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']

type teslaLength = Length<tesla>  // expected 4
type spaceXLength = Length<spaceX> // expected 5

Beginner 5-Length of Tuple: Explanation

Completed version:

type Length<T extends any[]> = T["length"]

T["length"]: Indexed Access Types for Properties

In JS, the length property of an array object returns the number of elements in the array. Also, index access is equivalent to accessing via ..

const arr = [1, 2, 3]

arr.length // becomes 3
arr["length"] // index access and "." access are equivalent

And since tuple types have a fixed length, the type of the length property for a tuple type becomes a numeric literal type.


Beginner 6-Exclude: Problem

Implement a type that excludes from T those types that are assignable to U, without using the built-in type utility Exclude <T, U>.

For example:

type Result = MyExclude<1 | 2 | 3 | 4, 1 | 3> // becomes 2 | 4

Beginner 6-Exclude: Explanation

Completed version

type MyExclude<T, U> = T extends U ? never : T

Distributive Conditional Types

Example: When a union type 1 | 2 is passed to T in MyExclude<T, U>, it is distributed as follows:

(1 | 2) extends U ? never : T
  => (1 extends U ? never : 1) | (2 extends U ? never : 2)

This is the same as the distributive law of multiplication in mathematics:

(1 + 2) \times u = (1 \times u) + (2 \times u)

More precisely, this occurs when a union type is passed to T in T extends U ? X : Y.


Beginner 6-Exclude: Explanation

Completed version

type MyExclude<T, U> = T extends U ? never : T

Additionally, when taking a union of the never type and other types, the never type has the property of being removed.

MyExclude<1 | 2 | 3, 2>
 => (1 extends 2 ? never : 1) | (2 extends 2 ? never : 2) | (3 extends 2 ? never : 3)
 => 1 | never | 3
 => 1 | 3

Beginner 7-Awaited: Problem

How can we retrieve the type wrapped within a Promise-like type?

For example: If there is a type like Promise<ExampleType>, how can we obtain ExampleType?

type R1 = MyAwaited<Promise<string>> // string
type R2 = MyAwaited<Promise<{ field: number }>> // { field: number }
type R3 = MyAwaited<Promise<Promise<string | number>>> // string | number
type R4 = MyAwaited<Promise<Promise<Promise<string | boolean>>>> // string | boolean

type Err = MyAwaited<string> // Error because it's not a Promise

Beginner 7-Awaited: Explanation

An almost-correct implementation:

type MyAwaited<T extends Promise<any>> = T extends Promise<infer U> ? U : never;

infer U: Type Inference

  • A keyword that can be used after Y in Conditional Types (X extends Y ? L : R).
  • In X extends Y<infer Z>, if X extends Y<any> is satisfied, the type matching the any part is assigned to the type variable Z, making it available in the L part.
MyAwaited<Promise<string>>
 => Promise<string> extends Promise<infer U /* inferred as string */ > ? U : never
 => string

However, this alone cannot handle nested Promises like the following:

MyAwaited<Promise<Promise<string>>>
 => Promise<Promise<string>> extends Promise<infer U> ? U : never
 => Promise<string>

Beginner 7-Awaited: Explanation

The problem can be solved by checking if the inferred U is a Promise<any> and, if so, calling it recursively.

type MyAwaited<T extends Promise<any>> 
  = T extends Promise<infer U>
    ? U extends Promise<any>
      ? MyAwaited<U>
      : U
    : never;

Example

MyAwaited<Promise<Promise<string>>>
 => Promise<Promise<string>> extends Promise<infer U /* inferred as Promise<string> */ >
    ? Promise<string> extends Promise<any>
      ? MyAwaited<Promise<string>>
      : Promise<string>
    : never;
 => MyAwaited<Promise<string>>
 => Promise<string> extends Promise<infer U /* inferred as string */ >
    ? string extends Promise<any>
      ? MyAwaited<string>
      : string
    : never;
 => string

Beginner 8-If: Problem

Implement If, which takes a condition C, a return type T for when C is truthy, and a return type F for when C is falsy.
C is expected to be either true or false, while T and F can be any types.

For example:

type A = If<true, 'a', 'b'>; // expected to be 'a'
type B = If<false, 'a', 'b'>; // expected to be 'b'

Beginner 8-If: Explanation

Completed version

type If<C extends boolean, T, F> = C extends true ? T : F

It suddenly became very simple...

By the way, boolean is equivalent to true | false.


Beginner 9-Concat: Problem

Implement the JavaScript Array.concat function in the type system. This type takes two arguments and returns a new array containing the elements of the received iterators in order.

For example:

type Result = Concat<[1], [2]>; // expected to be [1, 2]

Beginner 9-Concat: Explanation

Completed version

A pure knowledge problem.

type Concat<T extends any[], U extends any[]> = [...T, ...U]

[...T]: Variadic Tuple Types

  • When T is a tuple type, it returns a new tuple type with T expanded into a part of it.
type T1 = ["a", "b", "c"]

type T2 = ["start", ...T1, "end"] // becomes ["start", "a", "b", "c", "end"]

Beginner 10-Includes: Problem

Implement the JavaScript Array.include function in the type system. This type takes two arguments and must output true or false.

For example:

type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`

However, you may use Equal<T, U>.

Equal<1, 1> // true
Equal<1, "a"> // false

Beginner 10-Includes: Explanation

Implementing as far as we can understand simply.

type Includes<T extends any[], U> = /* Think later */ ? true : false

Ideally, we want to extract elements from T one by one like a for loop, compare them with U, and return true if they match. However, since there is no for loop, we consider recursion.

  1. If T is [], return false.
  2. Compare the type of the first element of T with U.
    • If they are equal, return true.
  3. If they are not equal, prepare a tuple R that removes the first element from T.
  4. Return Includes<R, U>.
// Pseudo-code
includes([1, 2, 3], 2)
  => 1 != 2, so return includes([2, 3], 2)
  => 2 == 2, so return true

Beginner 10-Includes: Explanation

Implementing steps 1 and 2 of the recursive step partially.

Implemented up to the point of extracting the first element H and the remainder R from tuple T, and returning false if it is empty.

type Includes<T extends any[], U> = T extends [infer H, ...infer R]
  ? /* Think later */
  : false;

T extends [infer H, ...infer R] ? X : Y

  • A combination of Conditional Types, Variadic Tuple Types, and Type Inference (a new feature in TS 4.0).
  • When T is a tuple type, H is assigned the type of the first element of T, and R is assigned the tuple type with the first element removed.
  • Example: [1, 2, 3] extends [infer H, ...infer R]H is 1, R is [2, 3]
  • Example: [1] extends [infer H, ...infer R]H is 1, R is []
  • Example: [] extends [infer H, ...infer R] → It doesn't hold in the first place, so Y is called.

Beginner 10-Includes: Explanation

Extracted the first element H and the rest R from tuple T.

type Includes<T extends any[], U> = T extends [infer H, ...infer R]
  ? /* Think later */
  : false;

Completed version

All that's left is to compare H and U, and if they are equal return true, otherwise return Includes<R, U>.

type Includes<T extends any[], U>
  = T extends [infer H, ...infer R]
    ? Equal<{H}, U> extends true
      ? true
      : Includes<{R}, U>
    : false;

Beginner 10-Includes: Explanation

Completed version

type Includes<T extends any[], U>
  = T extends [infer H, ...infer R]
    ? Equal<{H}, U> extends true
      ? true
      : Includes<{R}, U>
    : false;

Example: When T is [1, 2, 3] and U is 2

Includes<[1, 2, 3], 2>
 => [1, 2, 3] extends [infer H /* 1 */, ...infer R /* [2, 3] */]
    ? Equal<1, 2> extends true
      ? true
      : Includes<[2, 3], 2>
    : false;
 => Includes<[2, 3], 2>
 => [2, 3] extends [infer H /* 2 */, ...infer R /* [3] */]
    ? Equal<2, 2> extends true
      ? true
      : Includes<[3], 2>
    : false;
 => true

Beginner 11-Push: Problem

Implement a generic version of Array.push.

For example:

type Result = Push<[1, 2], boolean> // [1, 2, boolean]

Beginner 11-Push: Explanation

Completed version

Solved in one shot with Variadic Tuple Types

type Push<T extends any[], U> = [...T, U]

Beginner 12-Unshift: Problem

Implement the type version of Array.unshift.

For example:

type Result = Unshift<[1, 2], 0> // [0, 1, 2]

Beginner 12-Unshift: Explanation

Completed version

With Variadic Tuple Types (...)

type Unshift<T extends any[], U> = [U, ...T]

Beginner 13-Parameters: Problem

Implement a type that constructs a tuple type from T without using the built-in type utility Parameters<T>.

For example:

type F = (arg1: string, arg2: number) => void // Function type

// Example usage of function type
function f(arg1: string, arg2: number) {
  console.log("foo")
}
const f1: F = f

// Example usage of function type with an arrow function
const f: F = (arg1: string, arg2: number): void => {
  console.log("foo")
}

type FunctionParamsType = MyParameters<F> // becomes [string, number]

Beginner 13-Parameters: Explanation

Completed version

infer can also be used for function types

type MyParameters<T extends (...args: any[]) => any>
  = T extends (...args: infer U) => any
    ? U
    : never;

Final Thoughts

  • It was a continuous series of surprises, like "You can write this here!?"
  • I was able to break free from my own preconceptions about types.
  • I tried the Intermediate level up to the middle afterward, and it became fun writing recursive logic within those unique constraints.
  • I highly recommend you try the rest yourself.
  • Things not explained:
    • The relationship between never and any types
    • The difference between extends and Equal<T, U>
    • Implementation of Equal<T, U>
    • And so on...

Discussion