beraliv

Advanced types / Holy.js notes

Max and I discuss StringToNumber on Holy.js
Max and I discuss StringToNumber on Holy.js

On November, 5, together with Max we had a talk about Advanced types in TypeScript on HolyJS. Let's sum it up.

Table of contents

Why do we need types?

Based on the book of Benjamin C. Pierce – Types and Programming Languages, type systems in general are good for:

  1. Detecting errors
  2. Abstraction
  3. Documentation
  4. Language safety
  5. Efficiency

Of course, it allows early detection of some programming errors. It can be not only trivial mental slips (e.g. forgetting to convert a string to a number), but also deeper conceptual errors (e.g. neglecting a boundary condition, confusing units and etc.)

Regarding abstraction, type systems structure large systems in terms of modules with clear interfaces.

Types are also useful when reading programmes and cannot be outdated unlike comments.

If we talk about safety, we can define it as types make it impossible to shoot yourself in the foot while programming.

And last but not least, it's efficiency as high-performance compilers rely heavily on information gathered by the type checker during optimisation and code-generation phases.

In terms of TypeScript, we use it for the same reasons. Let's focus on the first 3 items:

  1. Detecting errors
Analysing AST-tree, TypeScript finds and shows errors
1type Status = "loading" | "loaded" | "error";
2
3let currentStatus: Status;
4
5currentStatus = "loading";
6currentStatus = "loaded";
7currentStatus = "error";
8// Type '"lagged"' is not assignable to type 'Status'
9currentStatus = "lagged";
  1. Abstraction
Creating new entities with types and interfaces
1type SquareMeters = number;
2
3interface Room {
4 area: SquareMeters;
5}
6interface Flat {
7 rooms: Room[];
8}
9interface House {
10 flats: Flat[];
11}
12interface District {
13 houses: House[];
14}
15interface City {
16 districts: District[];
17}
18interface Country {
19 cities: City[];
20}
  1. Documentation
React component Label which accepts object props with 2 fields – selected and title
1interface LabelPropsType {
2 selected?: boolean;
3 title: string;
4}
5
6export const Label = ({ selected = false, title }: LabelPropsType) => (
7 <a href={selected ? `/search` : `/search?label=${title}`}>{title}</a>
8);

Basic types

Before discussing advanced types, I would like to touch on some examples of the basic types:

  1. Pick allows us to get the object with the fields that we need.
Example of Pick
1interface Person {
2 name: string;
3 age: number;
4 alive: boolean;
5}
6
7// { name: string; }
8type PersonWithNameOnly = Pick<Person, "name">;
9// { name: string; age: number; }
10type PersonWithNameAndAge = Pick<Person, "name" | "age">;
  1. Exclude allows to remove elements from union type
Example of Exclude
1type Status = "loading" | "loaded" | "error";
2
3// "loading" | "loaded"
4type StatusWithoutErrorOnly = Exclude<Status, "error">;
5// "error"
6type ErrorStatus = Exclude<Status, "loading" | "loaded">;
  1. We use never in different constructions. We can draw an anology between never for union types and the empty set.
Example of keyword never
1type Status = "loading" | "loaded" | "error";
2
3// never
4type SuccessStatus = Exclude<Status, Status>;
  1. Tuples, which works like arrays but the number of elements is always fixed.
Example of tuples
1type Statuses = ["loading", "loaded", "error"];
2type VideoFormats = ["mp4", "mov", "wmv", "avi"];
3type EmptyTuple = [];
Spread in tuples
1type TupleWithZero = [0];
2
3type Test1 = [1, ...TupleWithZero, 1]; // [1, 0, 1]
4type Test2 = [...TupleWithZero, 1, ...TupleWithZero]; // [0, 1, 0]
  1. Arrays, to be able to store multiple values, e.g. array of numbers or strings.
Example of arrays
1interface School {
2 log: Record<string, number[]>;
3}
4
5type MathMarks = School["log"]["math"]; // number[]
6type Subjects = (keyof School["log"])[]; // string[]

Advanced types

Let's now discuss some examples of advanced types

  1. Construction like T extends string can be used in 2 cases: Generic constrains (where we can restrict the accepted types) and conditional types (where e.g. we can check if it's a string or not)
Examples of generic constraints and conditional types
1type AcceptsStrings<T extends string> = `${string}${T}`;
2type IsString<T> = T extends string ? true : false;
3
4// ❌ Type 'number' does not satisfy the constraint 'string'
5type Test1 = AcceptsStrings<number>;
6type Test2 = IsString<number>; // false

These 2 examples show the difference: number in Test1 will be marked as the error in Typescript while Test2 will be just false.

  1. Mapped types allow us to create an object with new keys and values. For example, we made 2 objects with identical keys and values.
Example of mapped types
1type KeyToKeyMapping<Keys extends PropertyKey> = { [K in Keys]: K };
2
3// { 1: 1, 2: 2, 3: 3; }
4type Test1 = KeyToKeyMapping<1 | 2 | 3>;
5// { a: "a"; b: "b"; c: "c"; }
6type Test2 = KeyToKeyMapping<"a" | "b" | "c">;
  1. Conditional types can use keyword infer to be able to identify what we have on the specific place.
Inference in conditional types
1// type ReturnType<T> = T extends (...args: any) => infer R ? R : any
2
3type Test1 = ReturnType<() => void>; // void
4type Test2 = ReturnType<() => number>; // number
5type Test3 = ReturnType<() => boolean>; // boolean

If we use basic type ReturnType, we can get the return value of the function, e.g. void, number and boolean respectively.

  1. Given type or interface, we can get the keys with keyword keyof
Examples of keyof
1type Person = { name: string };
2type School = { pupils: Person[]; teachers: Person[] };
3
4type Test1 = keyof Person; // "name"
5type Test2 = keyof School & string; // "pupils" | "teachers"
  1. Recursive condition types are condition types where we can use another recursive call to get results.
Putting characters into the tuple
1type CharacterIteration<T> = T extends `${infer Ch}${infer Rest}`
2 ? [Ch, ...CharacterIteration<Rest>]
3 : [];
4
5type Test1 = CharacterIteration<"123">; // ["1", "2", "3"]
6type Test2 = CharacterIteration<"">; // []

Also here we can see the iteration over string literal type using T extends `${infer Ch}${infer Rest}`

  1. Using [T] extends [U] we can check that one type can equal to another one. But we also have an exception in any here.
Check if types are equal
1type Equals<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false;
2
3type Test1 = Equals<never, never>; // true
4type Test2 = Equals<unknown, any>; // true 🧐
5type Test3 = Equals<1, number>; // false
  1. Check for a string literal type which still requires T extends string conditional type to return false for anything other than string
Check for string literal type
1type IsStringLiteral<T> = T extends string
2 ? string extends T
3 ? false
4 : true
5 : false;
6
7type Test1 = IsStringLiteral<string>; // false
8type Test2 = IsStringLiteral<"123">; // true
9type Test3 = IsStringLiteral<123>; // false
  1. To be able to convert tuples to number literal type, we can use length
Tuple to number literal type examples
1type Test1 = []["length"]; // 0
2type Test2 = [1, 2, 3]["length"]; // 3
  1. This conditional types is used for union type elements iteration and it's called distributed conditional types
Example of distributed conditional types
1type UnionIteration<T> = T extends unknown ? [T] : never;
2
3type Test1 = UnionIteration<never>; // never
4type Test2 = UnionIteration<1>; // [1]
5type Test3 = UnionIteration<1 | 2>; // [1] | [2]

Basic or advanced types?

Let's have a look at Advanced Types | TypeScript docs v1 where the advanced type was defined. It says:

This page lists some of the more advanced ways in which you can model types, it works in tandem with the Utility Types doc which includes types which are included in TypeScript and available globally.

In Creating Types from Types | TypeScript docs v2 TypeScript steps aside from this term, but the idea is still the same:

By combining various type operators, we can express complex operations and values in a succinct, maintainable way.

And below there are 7 ways to express a new type in TypeScript:

Ways to express advanced types

We will discuss only 4 ways of expressing advanced types:

  1. Number literal types
  2. Tuples
  3. String or Template literal types
  4. Mapped types

Number literal types

We can come to number literal types from tuples by accessing Tuple['length']

Example of number literal types inference
1type ToTuple<T> = any; // implementation
2type BinaryToDecimal<T> = any; // implementation
3
4type BinaryNumber = "101";
5type Step1 = ToTuple<BinaryNumber>; // [1, 0, 1]
6type Step2 = BinaryToDecimal<Step1>; // [1, 1, 1, 1, 1]
7type Result = Step2["length"]; // 5

More details about number literal types in TypeScript Issue#26382 – Math with Number Literal Type

Tuples

We can create tuples

Creating tuples of different length
1type Statuses = ["loading", "loaded", "error"];
2type VideoFormats = ["mp4", "mov", "wmv", "avi"];
3type EmptyTuple = [];

We are able to iterate over them from the beginning and the end

Iteration over tuples
1type FindFromStart<U, T> = T extends [infer Head, ...infer Tail]
2 ? U extends Head
3 ? true
4 : FindFromStart<U, Tail>
5 : false;
6
7type Example1 = FindFromStart<1, [1, 2, 3]>; // true
8type Example2 = FindFromStart<0, [1, 2, 3]>; // false
9
10type FindFromEnd<U, T> = T extends [...infer Start, infer Last]
11 ? U extends Last
12 ? true
13 : FindFromEnd<U, Start>
14 : false;
15
16type Example3 = FindFromEnd<1, [1, 2, 3]>; // true
17type Example4 = FindFromEnd<0, [1, 2, 3]>; // false

As you see, we also used them in conditional types to match some pattern. For example, the tuple T has at least one element with T extends [infer Head, ...infer Tail]

And also we can transform string literal types to tuples if we have difficulties doing it for string literal types.

String literal types

We can create string literal types

Creating string literal types
1// types only
2type Mp4Extension = "mp4";
3// runtime + types
4let extension: Mp4Extension = "mp4";
5// @ts-expect-error ❌ Type '"mp3"' is not assignable to type '"mp4"'
6extension = "mp3";

We can iterate over them but from the beginning only

Iteration over string literal types
1type FindFromStart<U, T> = T extends `${infer Head}${infer Tail}`
2 ? U extends Head
3 ? true
4 : FindFromStart<U, Tail>
5 : false;
6
7type Example1 = FindFromStart<"1", "123">; // true
8type Example2 = FindFromStart<"0", "123">; // false

As for tuples, we also can use string literal types in conditional types to match some pattern. For example, the string literal type T has at least one character with T extends `${infer Head}${infer Tail}`

Mapped types

And finally, it's mapped types

We can extract either keys or values of just created mapped types. Also depending on the task, we can express new dependencies of keys and values

Identical keys and values with mapped types
1type KeyToKeyMapping<Keys extends PropertyKey> = { [K in Keys]: K };
2
3// { 1: 1, 2: 2, 3: 3; }
4type Test1 = KeyToKeyMapping<1 | 2 | 3>;
5// { a: "a"; b: "b"; c: "c"; }
6type Test2 = KeyToKeyMapping<"a" | "b" | "c">;

Type challenges

To understand how advanced types are working, let's have a look at type-challenges

It's the collection of TypeScript type challenges with online judge

This project is aimed at helping you better understand how the type system works, writing your own utilities, or just having fun with the challenges

Here we will concentrate on category "hard"

Testing challenges

If you're not familiar with the format of the challenges, one of the most important part of it – passing the tests. Here I want to let you know how it's working.

Playground format of taking challenges
1/*
2 Description about the challenge
3*/
4
5/* _____________ Your Code Here _____________ */
6
7type ChallengeToImplement<T> = any;
8
9/* _____________ Test Cases _____________ */
10import { Equal, Expect } from "@type-challenges/utils";
11
12type Parameter1 = any;
13type ExpectedResult1 = any;
14type Parameter2 = any;
15type ExpectedResult2 = any;
16
17type cases = [
18 Expect<Equal<ChallengeToImplement<Parameter1>, ExpectedResult1>>,
19 Expect<Equal<ChallengeToImplement<Parameter2>, ExpectedResult2>>
20];

Usually we have some implementation which we want to test (here it's ChallengeToImplement).

Then under a part with "Test Cases" we import two functions – Equal and Expect:

  • Expect accepts one parameter and check if it's true. Otherwise, we will see TypeScript error that it's not true.
  • Equal accepts two parameters and check that they are equal. If they are, it returns true.

At the bottom we create type cases where we define all the checks. If all checks are working, we won't see any TypeScript errors

Solutions

Tuple Filter

To show the potential of tuples, we have a challenge about TupleFilter

Split

Split is next challenge where we manipulate string literal types

StringToNumber

To get familiar with number literal types, we will check StringToNumber

GetOptional

And last challenge GetOptional is about mapped types

Conclusion

So we had a look at the basic and advanced types and their difference.

We've seen that the implementation of the solution for challenges requires advanced types and that even between different challenges we have a lot in common – terms, expressions and constructions.

Knowing how to work with the specific way of creating new types doesn't really mean that you will know all the ways without problems.

We also know that TypeScript has a wide set of terms (basic or advanced) which makes it possible to solve different types of challenges.

Regarding the usage, given advanced types, we're able to solve different challenges. And there are different projects, for example:

where those advanced types are already implemented and can be used in your project.

We can read the codebase now and contribute to these kinds of projects which makes us even better 🚀

typescript
Alexey Berezin profile image

Written by Alexey Berezin who loves London 🏴󠁧󠁢󠁥󠁮󠁧󠁿, players ⏯ and TypeScript 🦺 Follow me on Twitter