TypeScript - commonly known as JS and additional type annotations, it's correct way of describing it, but hides the potential which lies in the language. What if I would describe TypeScript as far more than that, as two languages in one shell?
TypeScript like every statically typed language has two levels - value and type level. Value level can be simply considered as just JavaScript, the whole grammar and syntax works at this level exactly like JS specification says should work. The second level - type level is the syntax and grammar which was created specially for TypeScript. TS has even more, we can distinguish three levels of the language - Type System Language, Type Annotations and the last but not least JavaScript.
Note type annotations level is a place where type system meets JS, so these are all annotations like
a: T
and assertionsa as T
.
The article will introduce to you, TypeScript type system (TSts) as a fully flavored language by itself, so be prepared 💪.
TypeScript type system language(TSts)🟦
When we think about any language, we consider values, variables, expressions, operators, functions. We have tools for performing data flow, we can branch the flow by conditions, we can iterate the flow by iterations or recursions. Lets investigate how much of these things can be seen in TypeScript type system?
Values and variables
What stands for value in TS type system? It's a type, value at this level is represented as a type. We can assign the value to the variable by typical assign operator.
// TSts🟦
type X = string;
type Y = number;
type Z = boolean;
At the left we have alias, name which we set for the value, on the right side we have the value. Variable X
has value string
.
Note I say variable because variable is a term we all programmers know, in TS there are only single assignment variables, so we cannot re-declare the value, naturally then its like JS
const
Note Type in TSts is not only like const, but also like immutable const, as it cannot be mutated. We can compose types but never change the original one.
type X = 1; /* is TSts🟦 equivalent for JS🟨 : */ const X = 1;
Types of types
In the next part of the article I will use such terms:
-
type
is the same thing asvalue
-
value
is the same thing astype
-
kind
is a type of the type
Kind can be something new here, in TypeScript type system, kind is something which defines another type shape, in the same way at JS level type annotation defines a JS value shape.
X extends string /*is TSts🟦 equivalent for annotated JS🟨 */ const X: string
Operators
Not surprisingly type level TypeScript has its own operators, below some of them
-
A = B
assign -
A & B
intersection -
A | B
union -
keyof A
-
A extends B ? C : D
condition -
K in T
iteration
// TSts🟦
type Z = X | Y // Z is either X or Y
type D = A & B | C // D is combined A and B or C
type Keys = keyof {a: string, b: boolean} // get property keys in form of union
Conditions and equality
As I wrote we have possibility to do conditions by condition operator(conditional type as TS docs say), how about checking if something is equal to another thing? In order to achieve such we need to understand that when we ask A extends B
it means if A
then B
and A
can be used as B
, what conveys equality in the one direction (operation is not commutative), if A extends B
it doesn't implies that B extends A
. To check equality we need to perform the check in both directions.
// TSts🟦
type A = string
type B = "1"
type AisB = A extends B ? true : false // false
type BisA = B extends A ? true : false // true
As you can see B
can be used as A
but not in other way round.
// TSts🟦
type A = 1
type B = 1
type AisBandBisA = A extends B ? B extends A ? true : false : false // true
Above is full equality check, we check in two directions, and then types are considered equal.
Note the equality implemented like that has some gotchas and doesn't properly work in some cases. Example of such case you can find in the playground. Special thanks for Titian Cernicova Dragomir for this remark.
Note This naive implementation works also incorrectly for union types
Note Very important to mention is the fact that such conditions and equality work in a logical way for sound types, but TS has also unsound ones like
any, unknown, never
, these types can give us quite surprising results and equality doesn't work correctly for these types.
Functions
Functions are something fundamental for basic abstraction. Fortunately in TS type system there are functions, functions working with types which are commonly named - generic types. Lets create a function which will check any two values to be equal:
// TSts🟦
type IsEqual<A, B> = A extends B ? B extends A ? true : false : false
// use it
type Result1 = IsEqual<string, number> // false
type Result2 = IsEqual<1, 2> // false
type Result3 = IsEqual<"a","a"> // true
Function IsEqual
has two arguments A, B
which can be every type. So function works with any kind of type (single arity kind *
). But we can create functions with more precised arguments requirements.
// TSts🟦
type GetLength<A extends Array<any>> = A['length']
type Length = GetLength<['a', 'b', 'c']> // evaluates to 3
Function GetLength
is a function which works only with types being an Array<any>
kind. Take a look again at these two functions, if I put them right before JS functions what would you see?
// TSts🟦
type IsEqual<A, B>
= A extends B
? B extends A
? true
: false
: false
// JS🟨
const isEqual = (a:any, b: any) => a == b ? b == a ? true : false : false
// TSts🟦
type GetLength<A extends Array<any>> = A['length']
// JS🟨
const getLength = (a: Array<any>) => a['length']
Almost the same thing, don't you think? I hope now you are quite convinced that popular generic types are just functions evaluated at the compile time 💪
Composing functions
If we have functions, then it is natural to think there is possibility to call one function in another. As an example lets reuse written before IsEqual
function and use it inside body of another function IfElse
.
// TSts🟦
type IfElse<A, B, IfTrue, IfFalse> =
IsEqual<A, B> extends true ? IfTrue : IfFalse
type Result1 = IfElse<0, 1, 'Equal', 'Not Equal'> // Not Equal
type Result2 = IfElse<1, 1, 'Equal', 'Not Equal'> // Equal
Local variables
We have functions, we have also variables, but can we have function local scope variables? Again yes, at least we can have some illusion of them which is quite handy.
// TSts🟦
type MergePropertyValue<
A,
B,
Prop extends (keyof A & keyof B),
_APropValue = A[Prop], // local variable
_BPropValue = B[Prop]> // local variable
= _APropValue | _BPropValue // sum type
// JS🟨 take a look at similar JS function but working at assumed number fields
function mergePropertyValue(a, b, prop) {
const _aPropValue = a[prop];
const _bPropValue = b[prop];
return _aPropValue + _bPropValue; // sum
}
In the list of arguments, at the end we can put local variables and assign value to them, it is a great tool for aliasing evaluated constructs. In above example we didn't gain a lot, but such local aliases can be handy if the type is more complicated, and we can also use other function there! Let's try to make equality check for three arguments.
// TSts🟦
type AreEqual<
A,
B,
C,
_AisB = IsEqual<A, B>,
_BisC = IsEqual<B, C>,
> = _AisB extends true ? IsEqual<_AisB, _BisC> : false
type Result = AreEqual<1,1,1> // true
type Result2 = AreEqual<1, 2, 1> // false
type Result3 = AreEqual<'A', 'A', 'A'> // true
type Result4 = AreEqual<'A', 'A', 'B'> // false
In above definition _AisB
and _BisC
can be considered as local variables of AreEqual
function.
Note issue with saying that these are local variables is in a fact that we can ruin our type behavior from outside by setting those arguments like
AreEqual<1, 1, 1, false, false>
.Note underscore which I have used above for local variables is only convention. The purpose is to give a hint that these arguments should not be touched.
Loops
Every language has a way to iterate over a data structure, TSts isn't here an exception.
// TSts🟦
type X = {a: 1, b: 2, c: 3}
type Y = {
[Key in keyof X]: X[Key] | null
} // {a: 1 | null, b: 1 | null, c: 1 | null}
Type Y
is evaluated by iterating in for in
loop style over type X
, to every field of X
we append additional value null
. TSts can do more, we can even just do iteration, lets say from 0 to 5.
// TSts🟦
type I = 0 | 1 | 2 | 3 | 4 | 5
type X = {
[Key in I]: Key
}
// X is [0, 1, 2, 3, 4, 5]
// JS🟨 look at JS similar code
const x = []
for (let i = 0; i<= 6; i++) {
x.push(i);
}
We just have generated type which represents 6-elements array with values from 0 to 5. Its amazing, on the type level we have iterated from i=0
to i=5
and pushed i
to array. Looks like for loop
doesn't it?
Caution Hey reader 👋, remember all the time we talk about types, every time I say value its the same as type. 0,1... above are types which have only single exactly the same JS runtime representation, but still we work with types only.
Recursion
Recursion is a situation when function inside the definition call itself. Can we call the same function inside its body? Yes we can!
// TSts🟦
type HasValuesOfType<T extends object, F> = ({
[K in keyof T]: T[K] extends F ? true : T[K] extends object ? HasValuesOfType<T[K], F> : false
}[keyof T]) extends false ? false : true
Above function HasValuesOfType
is traversing argument being an kind of object (type of types). Function is checking if the value of the property has given type, if yes, its saying true
, if not, it does the recursive call to itself if the property is also an object. In the result function will tell us if at any level of the type there exists the wanted type.
Mapping, filtering and reducing
The language is capable of conditions, looping recursion, lets try to use those tools in order to transform types.
Mapping
// TSts🟦
type User = {
name: string,
lastname: string
}
type MapUsers<T extends Array<User>> = {
[K in keyof T]: T[K] extends User ? { name: T[K]['name'] } : never
}
type X = [{
name: 'John',
lastname: 'Doe'
}, {
name: 'Tom',
lastname: 'Hanks'
}]
type Result = MapUsers<X> // [{name: 'John'}, {name: 'Tom'}]
Function MapUsers
works with array of users kind, and maps every user by removing lastname
. Take a look how we map - { name: T[K]['name']}
, in every iteration over the type T
, we get value at this point T[K]
and take name
property which we put to the new value.
Filtering
TSts gives us tools for simple filtering object types. We can make function FilterField
which will perform removing field from an object kind of value.
// TSts🟦
type FilterField<T extends object, Field extends keyof T> = {
[K in Exclude<keyof T, Field>]: T[K]
}
// book
type Book = {
id: number,
name: string,
price: number
}
type BookWithoutPrice = FilterField<Book, 'price'> // {id: number, name: string}
Note filtering tuple types is not so simple, language doesn't support it without very sophisticated tricks which are outside the scope of this article.
FilterField
is doing iteration over T
, but by using Exclude
it is excluding Field
from list of keys, in result we get object type without this field.
Note there is utility type
Pick
andOmit
which can be used instead ofFilterField
Reducing
Reducing or folding is a transforming data from a shape A
🍌 into some other shape B
🌭. Can we do that and transform data from kind A
to kind B
? Sure we can 😎, even we did that already in previous examples. Lets for example sum how many properties has our object given as argument. Caution this one can be hard to grasp, but what I want to show here is a power of the language:
// TSts🟦
type Prepend<T, Arr extends Array<any>> = ((a: T, ...prev: Arr) => any) extends ((...merged: infer Merged) => any) ? Merged : never
type KeysArray<T extends object, ACC extends Array<any> = []> = ({
[K in keyof T]: {} extends Omit<T, K> ? Prepend<T[K], ACC> : KeysArray<Omit<T, K>, Prepend<T[K], ACC>>
}[keyof T]);
type CountProps<T extends object, _Arr = KeysArray<T>> = _Arr extends Array<any> ? _Arr['length'] : never;
type Y = CountProps<{ a: 1, b: 2, c: 3, d: 1 }> // Evaluates to 4
Yes a lot of code, yes quite complicated, we needed to use some additional helper type Prepend
and KeysArray
, but finally we were able to count the number of properties in the object, so we've reduced the object from { a: 1, b: 2, c: 3, d: 4 }
to 4
🎉.
Tuple transformations
TypeScript 4.0 introduced variadic tuple types which gives more tools to our TSts language level. We now very easily can remove, add elements or merge tuples.
// merging two lists
// TSts🟦
type A = [1,2,3];
type B = [4,5,6];
type AB = [...A, ...B]; // computes into [1,2,3,4,5,6]
// JS🟨 - the same looking code at value level
const a = [1,2,3];
const b = [1,2,3];
const ab = [...a,...b];
// push element to the lists
// TSts🟦
type C = [...A, 4]; // computes into [1,2,3,4]
// JS🟨 - the same looking code at value level
const c = [...a, 4];
// unshift element to the list
// TSts🟦
type D = [0, ...C]; // computes into [0,1,2,3,4]
// JS🟨 - the same looking code at value level
const d = [0, ...c];
As we can see thanks to variadic tuple types, operations on tuples at TSts look very similar to operations on Arrays in JS with using spread syntax.
Note Tuple type at our type level can be considered just as a list.
String concatenation
Concatenation of strings for TS > 4.1 is also not a problem anymore. We can glue strings at the type level in almost the same way we do that in value level.
// concatenate two strings
// TSts🟦
type Name = "John";
type LastName = "Doe";
type FullName = `${Name} ${LastName}`; // "John Doe"
// JS🟨 - the same looking code at value level 🤯
const name = "John";
const lastName = "Doe";
const fullName = `${name} ${lastName}`;
What about concatenation of strings in the list?
// TSts🟦
type IntoString<Arr extends string[], Separator extends string, Result extends string = ""> =
Arr extends [infer El,...infer Rest] ?
Rest extends string[] ?
El extends string ?
Result extends "" ?
IntoString<Rest, Separator,`${El}`> :
IntoString<Rest, Separator,`${Result}${Separator}${El}`> :
`${Result}` :
`${Result}` :
`${Result}`
type Names = ["Adam", "Jack", "Lisa", "Doroty"]
type NamesComma = IntoString<Names, ","> // "Adam,Jack,Lisa,Doroty"
type NamesSpace = IntoString<Names, " "> // "Adam Jack Lisa Doroty"
type NamesStars = IntoString<Names, "⭐️"> // "Adam⭐️Jack⭐️Lisa⭐️Doroty"
Above example maybe looks little bit more complicated, but proves that we can have generic type level function which will concatenate strings with given separator.
Higher order functions?
Is TSts functional language, is there possibility to pass functions and return functions? Below some naive try example
// TSts🟦
type ExampleFunction<X> = X // identity function
type HigherOrder<G> = G<1> // 🛑 higher order function doesn't compile
type Result = HigherOrder<ExampleFunction> // 🛑 passing function as argument doesn't compile
Unfortunately (or fortunately) there is no such option, at type level that sort of thing has a name - Higher Kinded Types, such constructs are available in for example Haskell programming language.
It also means we cannot create polymorphic functions like map, filter and reduce, as those functional constructs demand kind * -> *
(function) as argument.
Standard library
Every language has some standard library, no difference with TypeScript type level language. It has standard library, called in official documentation "utility types". Despite the name, utility types are type level functions included in TypeScript. These functions can help with advanced type transformations without the need of writing everything from scratch.
In summary
TypeScript type system TSts is something which should be considered as fully flavored language, it has all the things any language should have, we have variables, functions, conditions, iterations, recursion, we can compose, we can write sophisticated transformations. Type system is expression based and operates only on immutable values(types). It has no higher order functions, but it doesn't mean will not have them 😉.
Additional links:
- TypeScript is Turing complete
- Binary Arithmetic in TypeScript's Type System
- TS toolbelt - library with functions for type level TS
- Advanced TypeScript Exercises series
If you want to know about TypeScript and interesting things around it please follow me on dev.to and twitter.
Top comments (12)
Thank you Maciej! The type system seemed overly complicated until I read this. You made it understandable by comparing snippets of the type language against Javascript idioms. It's all starting to make sense.
Several days ago I have been reading about typescript and I still can't find the advantage to use it, I was looking for that maybe it could be a great tool for handling numbers but funny there is no implementation of float, double etc. only "number" maybe you could guide me if there was something in typescript to handle numbers more precisely. for example differences of decimal places of integers, of positives of negatives etc.
The question is advantage over what? You need to consider that TypeScript is very pragmatic language in terms of its connection with JavaScript. The data level is not touched, it means that TS has the same primitive types as JS has, but TS makes them explicit.
TypeScript is JavaScript with additional compile time processing of the code. The article focus at this additional type level which is added.
In terms of numbers, JS has primitive number type, TS follows that to be fully compatible. If you want to have language which solves JS issues in numbers you need to take a look at languages which are not compatible with JS. And there are few - Elm, ReasonML, PureScript. There you don't have JS issues.
So TS is natural choice for JS devs, as you know most of the language day one. Its much harder to migrate the whole team to some fully functional language, therefor TS remains a sweet spot of productivity and reliability.
Well written article, I enjoyed reading this.
But there is an error or misconception you made in this article that I should point out.
You stated the following
type X = 1; /* is TSts🟦 equivalent for JS🟨 : */ const X = 1;
Declaring "type X = 1" in typescript is NOT equivalent to "const x = 1" in Javascript.
The reason for this is because in Typescript, declaring a variable with a type only, prevents you from changing the type of the variable thereafter, but it does not prevent you from assigning a different type of value to that variable.
For example if I do the following in Typescript
let age:number = 5
I can later set age to a different number without any repercussions. But I will not be able to assign a string to age.
As for const (which works the same in JS and TS) once you declare a variable with const you cannot re-declare it to something else even if it's of the same type. For example If I was to do
const age:number = 5(typescript)
or const x = 5(Javascript)
I cannot re-declare age or x to a different number later. You would receive a compile time error in Typescript or a Runtime Error in Javascript. Const keyword is more equivalent to the "final" keyword in Java
You can achive the so called HOF with a global URIToKind interface, look at how
fp-ts
does it, its pretty hacky but its the most we can doThanks Maciej, this is great news!
I finally learned something concrete about TS capabilities.
I got quite impressed. Thanks
Thank you for reading! If you want I have some series with advanced typescript Q/A, check it out - Typescript Exercises
Thanks for that great article! I've spotted a typo, near the start :'lannguage'
Thank you very much
Doing functions and map,filter,reduce etc... with types? that was insane😍
Thank you, it was great article with a different perspective that I had.🙏
ps. fix example in loops:
i<= 6;
should change to 5 or i< 6;
Brilliant! Great one-stop-shop for all the TS funkiness. Thanks!