My sales pitch for TypeScript

[2025-03-02] dev, typescript
(Ad, please don’t block)

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.

Notation used in this blog post  

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).

TypeScript benefit: auto-completion and detecting more errors during editing  

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.

Example: typos, incorrect types, missing arguments  

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:

Example: getting function results wrong  

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:

  • At the end, there is no return statement – which is true: We forgot to start line B with return and therefore implicitly return undefined after line B.
  • The implicitly returned undefinedis 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('');
}

Example: working with optional properties  

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)';
}

Example: forgetting switch cases  

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:

  • Before the switch statement, its type is 'red' | 'green' | 'blue'.
  • After we crossed off the cases '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():

Example: code handles some cases incorrectly  

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.

Type annotations for function parameters and results are good documentation  

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:

  • Argument items is an iterable over strings.
  • The callback receives a string and an index and returns a boolean.
  • The result of 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.

TypeScript benefit: better refactoring  

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.

Using TypeScript has become easier  

We now often don’t need an extra build step compared to JavaScript:

  • On server side JavaScript platforms such as Node.js, Deno and Bun, we can run TypeScript directly – without compiling it.
  • Most bundlers such as Vite have built-in support for TypeScript.

More good news:

  • Compiling TypeScript to JavaScript has become more efficient – thanks to a technique called type stripping which simply removes the type part of TypeScript syntax and makes no other transformations (more information).

Creating packages has also improved:

  • npm: Non-library packages can be published in TypeScript. Library packages must contain JavaScript plus declaration files (with type information). Generating the latter has also improved – thanks to a technique called isolated declarations.
  • JSR (JavaScript Registry) is an alternative to npm where packages can be uploaded as TypeScript. It supports a variety of platforms. For Node.js, it automatically generates JavaScript files and declaration files.

Alas, type checking is still relatively slow and must be performed via the TypeScript compiler tsc.

The downsides of using TypeScript  

  • It is an added layer on top of JavaScript: more complexity, more things to learn, etc.
  • npm packages can only be used if they have static type definitions. These days, most packages either come with type definitions or there are type definitions available for them on DefinitelyTyped. However, especially the latter can occasionally be slightly wrong, which leads to issues that you don’t have without static typing.
  • Configuring TypeScript via 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:
    • For my own projects, I’m now using a maximally strict tsconfig.json – which eliminated my doubts about what my tsconfig.json should look like.
    • Type stripping (see previous section) has clarified the role of tsconfig.json for me: With them, it only configures how type checking works. Generating JavaScript can be done without tsconfig.json.

TypeScript FAQ  

Is TypeScript code heavyweight?  

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:

  • The parameters set1 and set2 are Sets whose elements have the same type T.
  • The result is also a Set. Its elements have the same type as those of 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.

Is TypeScript trying to turn JavaScript into C# or Java?  

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.

TypeScript is more than OOP  

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.

Advanced usage of types seems very complicated. Do I really have to learn that?  

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:

  • Variables (type variables)
  • Functions with parameters (generic types with type parameters)
  • Conditional expressions C ? T : F (conditional types)
  • Loops over objects (mapped types)
  • Etc.

For more information on this topic, see “An overview of computing with types” in “Tackling TypeScript”.

Are complicated types worth it?  

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.

How long does it take to learn TypeScript?  

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?