Advanced types / Holy.js notes
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
- Basic and advanced types
- Type challenges
- Solutions
- Conclusion
Why do we need types?
Based on the book of Benjamin C. Pierce – Types and Programming Languages, type systems in general are good for:
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:
- Detecting errors
1type Status = "loading" | "loaded" | "error";23let currentStatus: Status;45currentStatus = "loading";6currentStatus = "loaded";7currentStatus = "error";8// Type '"lagged"' is not assignable to type 'Status'9currentStatus = "lagged";
- Abstraction
1type SquareMeters = number;23interface 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}
- Documentation
1interface LabelPropsType {2 selected?: boolean;3 title: string;4}56export 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:
Pick
allows us to get the object with the fields that we need.
1interface Person {2 name: string;3 age: number;4 alive: boolean;5}67// { name: string; }8type PersonWithNameOnly = Pick<Person, "name">;9// { name: string; age: number; }10type PersonWithNameAndAge = Pick<Person, "name" | "age">;
Exclude
allows to remove elements from union type
1type Status = "loading" | "loaded" | "error";23// "loading" | "loaded"4type StatusWithoutErrorOnly = Exclude<Status, "error">;5// "error"6type ErrorStatus = Exclude<Status, "loading" | "loaded">;
- We use
never
in different constructions. We can draw an anology betweennever
for union types and the empty set.
1type Status = "loading" | "loaded" | "error";23// never4type SuccessStatus = Exclude<Status, Status>;
- Tuples, which works like arrays but the number of elements is always fixed.
1type Statuses = ["loading", "loaded", "error"];2type VideoFormats = ["mp4", "mov", "wmv", "avi"];3type EmptyTuple = [];
1type TupleWithZero = [0];23type Test1 = [1, ...TupleWithZero, 1]; // [1, 0, 1]4type Test2 = [...TupleWithZero, 1, ...TupleWithZero]; // [0, 1, 0]
- Arrays, to be able to store multiple values, e.g. array of numbers or strings.
1interface School {2 log: Record<string, number[]>;3}45type MathMarks = School["log"]["math"]; // number[]6type Subjects = (keyof School["log"])[]; // string[]
Advanced types
Let's now discuss some examples of advanced types
- 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)
1type AcceptsStrings<T extends string> = `${string}${T}`;2type IsString<T> = T extends string ? true : false;34// ❌ 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
.
- Mapped types allow us to create an object with new keys and values. For example, we made 2 objects with identical keys and values.
1type KeyToKeyMapping<Keys extends PropertyKey> = { [K in Keys]: K };23// { 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">;
- Conditional types can use keyword
infer
to be able to identify what we have on the specific place.
1// type ReturnType<T> = T extends (...args: any) => infer R ? R : any23type Test1 = ReturnType<() => void>; // void4type Test2 = ReturnType<() => number>; // number5type 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.
- Given type or interface, we can get the keys with keyword
keyof
1type Person = { name: string };2type School = { pupils: Person[]; teachers: Person[] };34type Test1 = keyof Person; // "name"5type Test2 = keyof School & string; // "pupils" | "teachers"
- Recursive condition types are condition types where we can use another recursive call to get results.
1type CharacterIteration<T> = T extends `${infer Ch}${infer Rest}`2 ? [Ch, ...CharacterIteration<Rest>]3 : [];45type 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}`
- Using
[T] extends [U]
we can check that one type can equal to another one. But we also have an exception inany
here.
1type Equals<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false;23type Test1 = Equals<never, never>; // true4type Test2 = Equals<unknown, any>; // true 🧐5type Test3 = Equals<1, number>; // false
- Check for a string literal type which still requires
T extends string
conditional type to returnfalse
for anything other thanstring
1type IsStringLiteral<T> = T extends string2 ? string extends T3 ? false4 : true5 : false;67type Test1 = IsStringLiteral<string>; // false8type Test2 = IsStringLiteral<"123">; // true9type Test3 = IsStringLiteral<123>; // false
- To be able to convert tuples to number literal type, we can use
length
1type Test1 = []["length"]; // 02type Test2 = [1, 2, 3]["length"]; // 3
- This conditional types is used for union type elements iteration and it's called distributed conditional types
1type UnionIteration<T> = T extends unknown ? [T] : never;23type Test1 = UnionIteration<never>; // never4type 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:
- Generics - Types which take parameters
- Keyof Type Operator - Using the keyof operator to create new types
- Typeof Type Operator - Using the typeof operator to create new types
- Indexed Access Types - Using
Type['a']
syntax to access a subset of a type - Conditional Types - Types which act like if statements in the type system
- Mapped Types - Creating types by mapping each property in an existing type
- Template Literal Types - Mapped types which change properties via template literal strings
Ways to express advanced types
We will discuss only 4 ways of expressing advanced types:
- Number literal types
- Tuples
- String or Template literal types
- Mapped types
Number literal types
We can come to number literal types from tuples by accessing Tuple['length']
1type ToTuple<T> = any; // implementation2type BinaryToDecimal<T> = any; // implementation34type 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
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
1type FindFromStart<U, T> = T extends [infer Head, ...infer Tail]2 ? U extends Head3 ? true4 : FindFromStart<U, Tail>5 : false;67type Example1 = FindFromStart<1, [1, 2, 3]>; // true8type Example2 = FindFromStart<0, [1, 2, 3]>; // false910type FindFromEnd<U, T> = T extends [...infer Start, infer Last]11 ? U extends Last12 ? true13 : FindFromEnd<U, Start>14 : false;1516type Example3 = FindFromEnd<1, [1, 2, 3]>; // true17type 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
1// types only2type Mp4Extension = "mp4";3// runtime + types4let 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
1type FindFromStart<U, T> = T extends `${infer Head}${infer Tail}`2 ? U extends Head3 ? true4 : FindFromStart<U, Tail>5 : false;67type Example1 = FindFromStart<"1", "123">; // true8type 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
1type KeyToKeyMapping<Keys extends PropertyKey> = { [K in Keys]: K };23// { 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.
1/*2 Description about the challenge3*/45/* _____________ Your Code Here _____________ */67type ChallengeToImplement<T> = any;89/* _____________ Test Cases _____________ */10import { Equal, Expect } from "@type-challenges/utils";1112type Parameter1 = any;13type ExpectedResult1 = any;14type Parameter2 = any;15type ExpectedResult2 = any;1617type 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'strue
. Otherwise, we will see TypeScript error that it's nottrue
.Equal
accepts two parameters and check that they are equal. If they are, it returnstrue
.
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