<TypeLevel> pushes payload from the JavaScript runtime to the TypeScript compiler. The highly composable type level API gives us the superpower to free JS apps from unnecessary ballast.
Ideally, types describe the domain, both structure and behavior. The latter is a challenge. There are not many major languages which are capable of describing behavior on the type level. TypeScript is different, it empowers us to perform algorithmic type transformations.
TypeScript does not pretend anything how to use the types. This is where <TypeLevel> comes into play. <TypeLevel> is more than a toolkit of useful utility methods, it offers a solution that yields a mental model for type level programming in TypeScript.
Install <TypeLevel>
npm i -D typescript-typelevel
Type |
---|
Fn<A extends any[] = any[], R extends any = any> |
Type |
---|
Obj |
Keys<T> |
Values<T> |
Paths<T> |
Combine<T> |
Filter<T, V, C extends boolean = true> |
Type |
---|
And<C1 extends boolean, C2 extends boolean> |
Or<C1 extends boolean, C2 extends boolean> |
Not<C extends boolean> |
Equals<T1, T2> |
Extends<T1, T2> |
Is<T1, T2> |
IsIn<T, U> |
IsEach<T, U> |
IsEmpty<T> |
IsUniversal<T> |
Type |
---|
TupleToIntersection<T extends any[]> |
TupleToUnion<T extends any[]> |
UnionToIntersection<U> |
UnionToTuple<T> |
Type |
---|
Check<T> |
CheckError<Message = any, Cause = any, Help = any> |
CheckResult<T, C extends Check<T>[], K extends PropertyKey = 'typelevel_error'> |
JavaScript (JS) is a structurally typed language. Informally, two types are assignable (read: considered "equal"), if they share the same properties. JS is dynamically typed, errors are reported at runtime.
TypeScript (TS) adds a static type system on top of JS, we say TS is a superset of JS. In fact, the essence of TS's type system is very simple, it consits of a set of built-in types and operations for type composition.
type
a type alias that does not changeinterface
a type that may be extendedclass
a JS class typeenum
an enumeration
{}
indexed arrays aka 'objects'[]
arrays and tuples() => T
functionsstring
,number
,boolean
,bigint
,symbol
,undefined
,null
primitive types'abc'
,1
,true
, ... literal typesvoid
absence of any typeany
,unknown
,never
universal types
any
and unknown
are both at the top of the type hierarchy, every type extends them. Informally, they can be seen as union of all possible types. However, technically they are no union types.
unknown
is the neutral element of the type intersection &
operation.
A & unknown = A
A & any = any
any
and unknown
have different meanings. any
is treated as any type to make the compiler happy at the cost of opting-out of type checking. unknown
is similar to any
while staying type-safe.
never
is at the bottom of the type hierarchy, it can be seen as subtype of all existing types, a type that will never occur.
never
is the empty union and the neutral element of the type union |
operation.
A | never = A
Hint on matching objects:
Record<PropertyType, unknown>
matches indexed arraysRecord<PropertyType, any>
matches all types with an index structure, like objects, arrays, functions, interfaces and classes
T | U
union type, neutral elementnever
(the "empty union")T & U
intersection type, neutral elementunknown
T<U>
generic typekeyof T
keyof operatorT['prop']
indexed access type`..${T}..`
template literal type{ [K in keyof T]: U }
mapped typeT extends U ? V : W
conditional typeT extends infer U ? V : W
inferred type
Note: typeof
was intentionally not mentioned because it does not operate on the type level.
One of the most important concepts in TS is the distribution of union types over conditional types.
This Stack Overflow answer by Karol Majewski describes it best:
The term distributive refers to how union types should be treated when subjected to type-level operations (such as
keyof
or mapped types).
- Non-distributive (default) operations are applied to properties that exist on every member of the union.
- Distributive operations are applied to all members of the union separately.
Let's use an example.
type Fruit = | { species: 'banana', curvature: number } | { species: 'apple', color: string }Let's assume that, for some reason, you want to know all possible keys that can exist on a
Fruit
.Non-distributive
Your intuition may tell you to do:
type KeyOfFruit = keyof Fruit; // "species"However, this will give you only the properties that exist on every member of the union. In our example,
species
is the only common property shared by allFruit
.It's the same as applying
keyof
to the union of the two types.keyof ({ species: 'banana', curvature: number } | { species: 'apple', color: string })Distributive
With distribution, the operation is not performed on just the common properties. Instead, it is done on every member of the union separately. The results are then added together.
type DistributedKeyOf<T> = T extends any ? keyof T : never type KeyOfFruit = DistributedKeyOf<Fruit>; // "species" | "curvature" | "color"In this case, TypeScript applied
keyof
to each member of the union, and summed the results.keyof { species: 'banana', curvature: number } | keyof { species: 'apple', color: string }