Back

A Small Introduction to TypeScript

In this blog post I'll do my best to give a simple introduction to TypeScript. I'll assume some basic knowledge about JavaScript, but I'll try to explain the concepts in a way that is easy to understand.

There are many pros and cons to using TypeScript, but personally I really prefer writing TypeScript over JavaScript. I initially started programming with dynamically typed languages, but after spending some time with statically-typed languages I really noticed the benefits: Especially in larger projects, types can make bigger changes easier and the more strict your types are, the more confident you can be that you didn't break anything - often times the type-checker will catch your mistakes before you do. Furthermore it constrains the way you can express your programs to clean patterns: Often times hacky solutions also aren't as easily typeable, but just thinking about the types forces you to redesign the code in a way that is more maintainable.

Especially when working on a bigger codebase written by multiple developers the benefits of strongly-typed code really begin to surface: It's easier to code-review typed code and when developing new features modern editors will give you hints about parameter types even before starting the type-checker in a command line. Often times you'll end up needing less documentation and you'll be able to discover the API just by looking through autocompletion suggestions.

Enough rambling for today - let's start with the small introduction.

The Basics

Typescript files mostly look like JavaScript files, only a few declarations and expression types were added. Let's look an example:

function addOne(x) {
  return x + 1;
}
console.log(addOne(42));

This is just regular JavaScript code, without any types. Nonetheless the TypeScript type-checker will try to see if our code could be correct. Per default it will only reject code which is definitely incorrect and otherwise won't complain. In this case we defined a function addOne which takes a parameter x and return the result of the expression x + 1. The type-checker (in contrast to some alternatives) only examines our code in isolation: When type-checking our function declaration it won't consider the call we make in line 4 at all, it'll just look at the declaration itself. In this case it doesn't know anything about x and will thus set its type to any internally - that's basically the "I'll trust the programmer in this case" type (we'll learn what this means later), we can thus do anything with it. The type of the + expression is determined by its operands: As one of them is already any we'll have to continue to trust the developer here, thus the return type will be any as well.

So if this is already TypeScript why would I have to change anything? Leaving your code like this already gives you some rudimentary type-checking - it'll warn you if you do something stupid, but the type won't really help you and will only prevent very few errors. This is were type-annotations come in: By helping the type-checker and adding constraints we can have better type-checking and will get more of the benefits.

What even are types?

The previous example already skipped over some details so let's look at it once again, I used the term type without explaining it at all. At their core types are collections of values. Values in JavaScript can have a variety of different forms: They can be among other things be numbers (42), strings (hello), booleans (true) and objects like {x: 13}. Types are basically sets which contain a specific range of values. For example we have the type string which contains all of the string values: 42 is not an instance of this type, but "hello world" certainly is, the string "42" as well. Similarly there's a boolean type which only contains the two values true and false. Analogously there's a number type and an object type.

By declaring that a certain variable or expression has a certain type we signal to the type-checker that we want it to ensure that all values that this expression can have when we execute it, are of that type. For example when we declare a parameter x as number we basically tell the type-checker "We know that x only refers to number values", it thus can use that information to gather more information about our program and will know that x + 1 is a number as well. On the other hand it'll complain if we give it two declarations which don't make sense together. When we declare a variable y as a number and later assign "test" to that variable the compiler will know that we did something wrong and will complain. We previously said that we want the variable to only hold number values, but then assign a string value to it - that certainly can't be the intention of the programmer.

Type declarations

Let's revisit our example again: As TypeScript analyzes declarations in isolation (most of the time) it's a good idea to help it out by adding some parameter types:

function addOne(x: number) {
  return x + 1;
}
console.log(addOne(42));

TypeScript will quickly figure out that x + 1 has to be an integer as well, furthermore the function can only return this one expression and therefore it can only return values of type number. If we hover over addOne in VSCode we'll see that it is a function addOne(x: number): number - a function called addOne taking a parameter named x of type number and returning a number. When we now declare a variable y, initializing it with addOne(12) it'll figure out on its own that the type of y is number as well. In a first step it'll check whether a function addOne is defined in the current scope, will check whether the parameter value is definitely a number (12 is definitely a number) and will thus conclude that the type of the expression is the return-type of the function. Although the type of a variable is determined automatically (this process is called type-inference) we can actually still declare the type of it manually and the type-checker will complain when the types aren't compatible:

const y: number = addOne(12);

When does this make sense? It really depends on the intention you have - do you want y to always be a number or does it just have to be the type which addOne returns? If you change the type of addOne in the future you might not want to change all of the declarations, on the other hand if you are doing something with y where it is important that it is a number it might be preferable to add the type annotation.

Composite Types

Until now all types we considered were disjoint, each value is contained only by one of the primitive types (number, boolean, ...), but the power of TypeScript only becomes evident when we look at more complicated types. Composite types can be constructed using one or multiple other types: For two types a and b there's a union type a | b which contains all values which are either of type a or of type b (or both). For example there is a type expressed as number | string which contains all numbers and all strings, by declaring a variable as let x: number | string; we tell the type-checker that at runtime the value of this variable x will always either be a number or a string.

We'll be able to assign both a string value and a number to this variable:

let x: number | string = 12;
x = "hello"; // Valid
x = 42; // Also Valid

Of course we can also use these new types to declare the return-type of functions, let's look at an example which also demonstrates the syntax used for return-type annotations:

function foo(wantNumber: boolean): string | number {
  const y = 42;
  if (wantNumber) {
    return y;
  } else {
    return y.toString();
  }
}

The function foo either returns a number y or a string y.toString(), thus its correct return type is string | number and we can verify this using the return-type annotation : string | number.

There's also a type for arrays, denoted with type[] which contains all arrays where all elements are of that type: Thus ´[123, 53]is of typenumber[], but ["hello", "world", 42]is not of that type. Furthermore there is another operator, the intersection operator: The&operator can be used to limit the value set to all values which are contained in both types:number & string` would contain all values which are both a number and a string. Such values don't exist and thus there's no value we could assign a variable with that type to, but we can use union types to get a more sensible example:

(number | string) & (number | boolean);

In plain english this type contains all values which are both of the type (number | string) and of the type (number | boolean). Only numbers fullfil this condition, thus this type is equivalent to the number type.

Subtypes

The concept of subtypes is another fundamental concept. We previously already learned that types can restrict the values which we can assign to a variable, i.e. x = y is invalid if the type of x is string and the type of y is number. But do these types have to match exactly? No, they don't have to match: When telling TypeScript that a variable is of a certain type we only want it to complain if this promise might be broken. Thus we should be allowed to do this:

const x: number | string = 42;
const y: number | string | boolean = x;

Our promise that y will only have values which fulfill the condition (number | string | boolean) isn't broken, x can't have values which aren't allowed by y, thus this assignment is allowed. We can do this because number | string is a subtype of number | string | boolean because every value allowed by number | string is also allowed by number | string | boolean. It thus is the analog to the subset relation in set theory.

Every type is also a subtype of itself: Trivially every value of number is also allowed by number, thus they are also subtypes. On the other hand assigning y to x wouldn't be allowed in the example above:

declare const y: number | string | boolean;
const x: number | string = y;

(declare is a way of telling TypeScript "I promise that there will be a variable of this type in scope")

Any

Ok, I have to admit that I was lying the previous paragraph: The "subtype" relation isn't always determined by the subset relation of the underlying set of values, there is one major exception: any. any is a subtype of all other types and all types are subtypes of it as well. This means that you can assign all values to any, but you can also assign an expression of type any to all values. This makes any a very versatile, but also very dangerous type: You can always replace all types by any and your program will continue to work, but TypeScript will also allow you to do anything with these variables. This makes any the "cheat" type, essentially opting out of the type-system, eliminating its benefits. You can fool the type-checker by using any and make impossible things possible:

const x: any = 123;
const y: string = x;
// Now we have a number stored in a string variable

Because of its dangerous nature many TypeScript developers try not to use any and there are also ways to ensure that any isn't allowed to appear in the code-base. Nonetheless there are certain scenarios where using any is justified: When migrating a code-base to TypeScript you can use any for legacy JavaScript parts of your code and sometimes it's also useful if there are no types for an external library, but you are sure that you are using it correctly. Additionally in some edge-cases it might also be used to escape certain restrictions of the type-system while ensuring that your module has a nice public API.

Any also has a well-behaved sibling: unknown. Unknown is just like any in the sense that it allows all values, but in contrast to any it cannot be assigned to any other type: All types are subtypes of unknown, but there are no types other than unknown itself which unknown is a subtype of. But if I can't assign an unknown expression to any other variable, how can I use these values? The answer is: Using narrowing.

Type Narrowing

Type narrowing allows us to execute different code depending on the runtime type an expression has. Let's assume we want to implement a function sum taking an array as its argument and returning the sum of all number-valued elements. We'll start with a simple function declaration:

function sum(array: unknown[]): number {

This should all be self explanatory: unknown[] is simply an array type where the element value is arbitrary. The return-type will be number as the sum should always be aa number. We can simply iterate through the array:

// Accumulator variable
let acc = 0;
for (let i = 0; i < array.length; i++) {
  const element = array[i];
  // `element` has type `unknown` here
  // What do we do now?
}
return acc;

So what could we do in the for-loop? Most JavaScript developers will know the answer to this: We can use the typeof operator to get a string describing the runtime type of a value. In contrast to the formal type of a variable / expression the runtime type is only known when the program is executed and can be different for different iterations. Let's see if TypeScript is happy if we use it:

function sum(array: unknown[]): number {
  // Accumulator variable
  let acc = 0;
  for (let i = 0; i < array.length; i++) {
    const element = array[i];
    if (typeof element === "number") {
      acc += element;
    }
  }
  return acc;
}

And indeed this simply works in TypeScript. The type of a variable isn't necessarily fixed in TypeScript - it can change depending on the scope. Inside the block of the if-statement the type of element is number, outside of it it's still unknown. TypeScript knows that the true branch of the if statement is only executed when the type of the current element is number, it thus knows that element has to have the number type inside the block. number is also more precise than unknown, thus it can simply ignore the fact that the variable had the type unknown previously.

There are also a couple of methods and functions which can be used for type-narrowing and there's also a way to define such function yourself. Array.isArray is an example of a popular narrowing function which can be used to narrow between array and non-array types:

function foo(arg: number[] | number) {
  if (Array.isArray(arg)) {
    // arg is number[] here
  } else {
    // arg is number here
  }
}

As you can see in this example we can also use "negative" narrowing expressions: In the else branch of this if Statement we already know that arg cannot be an array, thus it can't be a number[] and has to be a number.

Another weird type is void, which is mostly used for function declarations in return-type annotations. It also (confusingly) allows all values, but it doesn't allow narrowing. You can use it if the function has no useful return value.

Objects

If you do anything serious in JavaScript you'll eventually need to use objects. In JavaScript objects are used both as dictionaries (although there is a separate Map constructor nowadays) and as structures with a finite key-set. Let's look at the latter use-case first by writing a simple function which creates a counter:

// For illustrative purposes only - I am not saying that you should write
// JavaScript like this.
function createCounter() {
  return {
    value: 0,
    getValue: function getValue() {
      return this.value;
    },
    increment: function increment() {
      this.value++;
    },
    decrement: function decrement() {
      this.value--;
    },
  };
}

It can be used like this:

const counter = createCounter();
counter.increment();
console.log(counter.value);

Without adding any annotations this is actually well-typed TypeScript code. The type of our createCountervariable is:

function createCounter(): {
  value: number;
  getValue: () => number;
  increment: () => void;
  decrement: () => void;
};

This is just the type of the function, not its definition. Examining it we'll see that createCounter is a always a function named createCounter and its return type is { value: number; getValue: () => number; increment: () => void; decrement: () => void; }. What does this mean? Since when do types look like this? This is an example of an object type, similarly to previous examples it contains all values which are objects and have at least four properties value, getValue, increment and decrement. The type of the value property has to be number and the type of the increment and decrement properties has to be () => void - the type of a function taking no arguments and returning no useful return value. getValue has to contain a function returning a number. The syntax for declaring object types is actually pretty similar to the syntax of object literals.

The type-system of Typescript (for the most part) is structural, which means that it only cares about the interface an object exposes, not where it comes from. It's only important that these four properties exist, not that createCounter created the object. It could also be some other class or function which create the counter and it would still be assignable to our counter variable.

Interfaces

Writing the object type using the bracket notation all the time can get repetitive, thus there are interface types: Interface types allow you to define new types with a specified name. For our example it would make sense to create a new interface type Counter which specifies the API of our counter objects. We can do that similarly to how classes are declared in JavaScript:

interface Counter {
  value: number;
  getValue: () => number;
  increment: () => void;
  decrement: () => void;
}

The declaration of the properties which have to exist on a value of this type are pretty similar to the bracket syntax, but our type annotation are more readable now. We can use Counter in all places where types can be specified. Let's add it to our declaration of createCounter:

function createCounter(): Counter {
  // ...
}

Now the type of createCounter becomes function createCounter(): Counter - much better. We can also use it in the variable declaration of our counter:

const counter: Counter = createCounter();

which ensures that if createCounter suddenly returns a value of a different type we are notified by an error.

Subtyping, Part 2

An interesting detail of object types is that subtypes can have additional properties: This means that we can hide implementation details by not declaring the value property in the Counter interface.

interface Counter {
  getValue: () => number;
  increment: () => void;
  decrement: () => void;
}

At first TypeScript will now complain about our createCounter function - it is noticing that we are defining a property which isn't required for the interface. Luckily there's a quick-fix for this, which ensures the type-checker knows that this is on purpose:

function createCounter(): Counter {
  const counter = {
    value: 0,
    getValue: function getValue() {
      return this.value;
    },
    increment: function increment() {
      this.value++;
    },
    decrement: function decrement() {
      this.value--;
    },
  };
  return counter;
}

Our counter variable will have a cryptic { value: number ... } type, but that type is a subtype of Counter so we are allowed to return it.

Furthermore console.log(counter.value) isn't allowed anymore - we can simply use the getValue property instead. Changing the Counter interface also allows us to modify the createCounter function so that it stores the current value somewhere else, for example in a local variable:

function createCounter(): Counter {
  let value = 0;
  return {
    getValue: function getValue() {
      return value;
    },
    increment: function increment() {
      value++;
    },
    decrement: function decrement() {
      value--;
    },
  };
}

Sometimes it's better to hide implementation details so that we can change several parts of the code independently without worrying about breaking something.

Casting

Casts are a new type of expression which allow you to define the type of an expression, promising the compiler that the expression has a specific type. Just like any casts can be harmful, but are still useful in some situations where you have no other option. Casts use the keyword as as aa binary operator:

const f = {} as Counter;

In this case the cast expresses the promise that the expression {} exposes the Counter interface - which is obviously not true, but TypeScript will trust us here. Casts where neither the expression type is a subtype of the asserted type nor the asserted type is a subtype of the expression type are disallowed by the type-checked: "test" as number won't type-check because it's trivial to see that this assertion is wrong. You can however persuade TypeScript by chaining two casts: "test" as unknown as number will cast "test" to unknown first and only then to number, both of which are allowed. Casts should be used sparingly, but are certainly a useful tool to have.

I hope you enjoyed this small introduction to TypeScript and maybe even learned something. There are a lot of advanced topics and techniques not explained here, but the essentials should be covered. If you have any suggestions, have feedback or found a mistake feel free to contact me. That's it for today - Cheers!