Roughly, TypeScript is JavaScript plus type information. The latter is removed before TypeScript code is executed by JavaScript engines. Therefore, writing and deploying TypeScript is more work. Is that added work worth it? In this blog post, I’m going to argue that yes, it is. Read it if you are skeptical about TypeScript but interested in giving it a chance.
In TypeScript code, I’ll show the errors reported by TypeScript via comments that start with @ts-expect-error
– e.g.:
// @ts-expect-error: The right-hand side of an arithmetic operation
// must be of type 'any', 'number', 'bigint' or an enum type.
const value = 5 * '8';
That makes it easier to automatically test all the source code in this blog post. It’s also a built-in TypeScript feature that can be useful (albeit rarely).
Let’s look at examples of code where TypeScript helps us – by auto-completing and by detecting errors. The first example is simple; later ones are more sophisticated.
class Point {
x: number;
y: number;
constructor(x: number, y = x) {
this.x = x;
this.y = y;
}
}
const point1 = new Point(3, 8);
// @ts-expect-error: Property 'z' does not exist on type 'Point'.
console.log(point1.z); // (A)
// @ts-expect-error: Property 'toUpperCase' does not exist on
// type 'number'.
point1.x.toUpperCase(); // (B)
const point2 = new Point(3); // (C)
// @ts-expect-error: Expected 1-2 arguments, but got 0.
const point3 = new Point(); // (D)
// @ts-expect-error: Argument of type 'string' is not assignable to
// parameter of type 'number'.
const point4 = new Point(3, '8'); // (E)
What is happening here?
Line A: TypeScript knows the type of point1
and it doesn’t have a property .z
.
Line B: point1.x
is a number and therefore doesn’t have the string method .toUpperCase()
.
Line C: This invocation works because the second argument of new Point()
is optional.
Line D: At least one argument must be provided.
Line E: The second argument of new Point()
must be a number.
In line A, we get auto-completion:
How many issues can you see in the following JavaScript code?
function reverseString(str) {
if (str.length === 0) {
return str;
}
Array.from(str).reverse();
}
Let’s see what TypeScript tells us if we add type annotations (line A):
// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'.
function reverseString(str: string): string { // (A)
if (str.length === 0) {
return str;
}
Array.from(str).reverse(); // (B)
}
TypeScript tells us:
return
statement – which is true: We forgot to start line B with return
and therefore implicitly return undefined
after line B.undefined
is incompatible with the return type string
(line A).If we fix this issue, TypeScript points out another error:
function reverseString(str: string): string { // (A)
if (str.length === 0) {
return str;
}
// @ts-expect-error: Type 'string[]' is not assignable to
// type 'string'.
return Array.from(str).reverse(); // (B)
}
In line B, we are returning an Array while the return type in line A says that we want to return a string. If we fix that issue too, TypeScript is finally happy with our code:
function reverseString(str: string): string {
if (str.length === 0) {
return str;
}
return Array.from(str).reverse().join('');
}
In our next example, we work with names that are defined via objects. We define the structure of those objects via the following TypeScrip type:
type NameDef = {
name?: string, // (A)
nick?: string, // (B)
};
In other words: NameDef objects have two properties whose values are strings. Both properties are optional – which is indicated via the question marks in line A and line B.
The following code contains an error and TypeScript warns us about it:
function getName(nameDef: NameDef): string {
// @ts-expect-error: Type 'string | undefined' is not assignable
// to type 'string'.
return nameDef.nick ?? nameDef.name;
}
??
is the nullish coalescing operator that returns its left-hand side – unless it is undefined
or null
. In that case, it returns its right-hand side. For more information, see “Exploring JavaScript”.
nameDef.name
may be missing. In that case, the result is undefined
and not a string. If we fix that, TypeScript does not report any more errors:
function getName(nameDef: NameDef): string {
return nameDef.nick ?? nameDef.name ?? '(Anonymous)';
}
Consider the following type for colors:
type Color = 'red' | 'green' | 'blue';
In other words: a color is either the string 'red'
or the string 'green'
or the string 'blue'
. The following function translates such colors to CSS hexadecimal color values:
function getCssColor(color: Color): `#${string}` {
switch (color) {
case 'red':
return '#FF0000';
case 'green':
// @ts-expect-error: Type '"00FF00"' is not assignable to
// type '`#${string}`'.
return '00FF00'; // (A)
default:
// (B)
// @ts-expect-error: Argument of type '"blue"' is not
// assignable to parameter of type 'never'.
throw new UnexpectedValueError(color); // (C)
}
}
In line A, we get an error because we return a string that is incompatible with the return type `#${string}`
: It does not start with a hash symbol.
The error in line C means that we forgot a case (the value 'blue'
). To understand the error message, we must know that TypeScript continually adapts the type of color
:
switch
statement, its type is 'red' | 'green' | 'blue'
.'red'
and 'green'
, its type is 'blue'
in line B.And that type is incompatible with the special type never
that the parameter of new UnexpectedValueError()
has. That type is used for variables at locations that we never reach. For more information see the 2ality post “The bottom type never
in TypeScript”.
After we fix both errors, our code looks like this:
function getCssColor(color: Color): `#${string}` {
switch (color) {
case 'red':
return '#FF0000';
case 'green':
return '#00FF00';
case 'blue':
return '#0000FF';
default:
throw new UnexpectedValueError(color);
}
}
This is what the error class UnexpectedValueError
looks like:
class UnexpectedValueError extends Error {
constructor(
// Type enables type checking
value: never,
// Avoid exception if `value` is:
// - object without prototype
// - symbol
message = `Unexpected value: ${{}.toString.call(value)}`
) {
super(message)
}
}
Lastly, TypeScript gives us auto-completion for the argument of getCssColor()
:
The following type describes content via objects. Content can be text, an image or a video:
type Content =
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
| {
kind: 'video',
width: number,
height: number,
runningTimeInSeconds: number,
}
;
In the following code, we use content incorrectly:
function getWidth(content: Content): number {
// @ts-expect-error: Property 'width' does not exist on
// type 'Content'.
return content.width;
}
TypeScript warns us because not all kinds of content have the property .content
. However, they all do have the property .kind
– which we can use to fix the error:
function getWidth(content: Content): number {
if (content.kind === 'text') {
return NaN;
}
return content.width; // (A)
}
Note that TypeScript does not complain in line A, because we have excluded text content, which is the only content that does not have the property .width
.
Consider the following JavaScript code:
function filter(items, callback) {
// ···
}
That does not tell us much about the arguments expected by filter()
. We also don’t know what it returns. In contrast, this is what the corresponding TypeScript code looks like:
function filter(
items: Iterable<string>,
callback: (item: string, index: number) => boolean
): Iterable<string> {
// ···
}
This information tells us:
items
is an iterable over strings.callback
receives a string and an index and returns a boolean.filter()
is another iterable over strings.Yes, the type notation takes getting used to. But, once we understand it, we can quickly get a rough understand of what filter()
does. More quickly than by reading prose in English (which, admittedly, is still needed to fill in the gaps left by the type notation and the name of the function).
I find it easier to understand TypeScript code bases than JavaScript code bases because, to me, TypeScript provides an additional layer of documentation.
This additional documentation also helps when working in teams because it is clearer how code is to be used and TypeScript often warns us if we are doing something wrong.
Whenever I migrate JavaScript code to TypeScript, I’m noticing an interesting phenomenon: In order to find the appropriate types for the parameters of a function or method, I have to check where it is invoked. That means that static types give me information locally that I otherwise have to look up elsewhere.
Refactorings are automated code transformations that many integrated development environments offer.
Renaming methods is an example of a refactoring. Doing so in plain JavaScript can be tricky because the same name might refer to different methods. TypeScript has more information on how methods and types are connected, which makes renaming methods safer there.
We now often don’t need an extra build step compared to JavaScript:
More good news:
Creating packages has also improved:
Alas, type checking is still relatively slow and must be performed via the TypeScript compiler tsc
.
tsconfig.json
also adds a bit of complexity and means that there is a lot of variation w.r.t. how TypeScript code bases are type-checked. There are two mitigating factors:
tsconfig.json
– which eliminated my doubts about what my tsconfig.json
should look like.tsconfig.json
for me: With them, it only configures how type checking works. Generating JavaScript can be done without tsconfig.json
.TypeScript code can be heavyweight. But it doesn’t have to be. For example, due to type inference, we can often get away with relatively few type annotations:
function setDifference<T>(set1: Set<T>, set2: Set<T>): Set<T> {
const result = new Set<T>();
for (const elem of set1) {
if (!set2.has(elem)) {
result.add(elem);
}
}
return result;
}
The only non-JavaScript syntax in this code is <T>
: Its first occurrence setDifference<T>
means that the function setDifference()
has a type parameter – a parameter at the type level. All later occurrences of <T>
refer to that parameter. They mean:
set1
and set2
are Sets whose elements have the same type T
.set1
and set2
.Note that we normally don’t have to provide the type parameter <T>
– TypeScript can extract it automatically from the types of the parameters:
assert.deepEqual(
setDifference(new Set(['a', 'b']), new Set(['b'])),
new Set(['a']),
);
assert.deepEqual(
setDifference(new Set(['a', 'b']), new Set(['a', 'b'])),
new Set(),
);
When it comes to using setDifference()
, the TypeScript code is not different from JavaScript code in this case.
Over time, the nature of TypeScript has evolved.
TypeScript 0.8 was released in October 2012 when JavaScript had remained stagnant for a long time. Therefore, TypeScript added features that its team felt JavaScript was missing - e.g. classes, modules and enums.
Since then, JavaScript has gained many new features. TypeScript now tracks what JavaScript provides and does not introduce new language-level features anymore – for example:
In 2012, TypeScript had its own way of doing modules. Now it supports ECMAScript modules and CommonJS.
In 2012, TypeScript started had its own implementation of classes that was transpiled to functions. Since ECMAScript 6 came out in 2015, it has supported the built-in classes.
In 2015, TypeScript introduced its own flavor of decorators, in order to support Angular. In 2022, ECMAScript decorators reached stage 3 and TypeScript has supported them since. For more information, see section “The history of decorators” in the 2ality post on ECMAScript decorators.
If the type checking option erasableSyntaxOnly
is active, TypeScript only supports JavaScript’s language features – e.g. we are not allowed to use enums. This option enables type stripping and seems popular among TypeScript programmers. Thus it looks like in the future, most TypeScript will really be pure JavaScript plus type information.
TypeScript will only get better enums or pattern matching if and when JavaScript gets them.
A common misconception is that TypeScript only supports a class-heavy OOP style; it supports many functional programming patterns just as well – e.g. discriminated unions which are a (slightly less elegant) version of algebraic data types:
type Content =
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
| {
kind: 'video',
width: number,
height: number,
runningTimeInSeconds: number,
}
;
In Haskell, this data type would look like this (without labels, for simplicity’s sake):
data Content =
Text Int
| Image Int Int
| Video Int Int Int
More information: “TypeScript for functional programmers” in the TypeScript Handbook.
Normal use of TypeScript almost always involves relatively simple types. For libraries, complicated types can be useful but then they are complicated to write and not complicated to use. My general recommendation is to make types as simple as possible and therefore easier to understand and maintain. If types for code are too complicated then it’s often possible to simplify them – e.g. by changing the code and using two functions instead of one or by not capturing every last detail with them.
One key insight for making sense of advanced types, is that they are mostly like a new programming language at the type level and usually describe how input types are transformed into output types. In many ways, they are similar to JavaScript. There are:
C ? T : F
(conditional types)For more information on this topic, see “An overview of computing with types” in “Tackling TypeScript”.
Sometimes they are – for example, as an experiment, I wrote a simple SQL API that gives you a lot of type completions and warnings during editing (if you make typos etc). Note that writing that API involved some work; using it is simple.
I believe that you can learn the basics of TypeScript within a day and be productive the next day. There is still more to learn after that, but you can do so while already using it.
I have written a chapter that teaches you those basics. If you are new to TypeScript, I’d love to hear from you: Is my assumption correct? Were you ready to start using (simple) TypeScript after reading it?