Overview of key TypeScript language features

Published: Thu Jul 21 2022

Here’s a short writeup of some key features that make TypeScript what is is. These are:

  • Type annotation (tl;dr: we can declare types of values through annotations)
  • Structural typing (tl;dr: types are compatible if they share the same structure)
  • Type inference (tl;dr: TS infers types for unannotated values based on surrounding annotations and usage)
  • Type erasure (tl;dr: types are only available at compile time, they’re not shipped to browsers)

Type annotation

In TypeScript, we can record the intended contract of of some value (be it a primitive, a function or an object) by adding type annotations, like so:

function sum(num1: number, num2: number): number {
  return num1 + num1
}

Now, the TypeScript compiler knows that the function sum expects two arguments of type number and returns a value of type number as the result.

Type inference

Type annotations often do not need to be added since the TypeScript compiler is pretty good at inferring types from contexts. We could omit the annotation for the function return type and TypeScript would know (and inform us) that it returns a number:

// return type annotation omitted
// function signature: function sum(num1: number, num2: number): number
function sum(num1: number, num2: number) {
  return num1 + num1
}

When types are ‘missing’ in that annotations have not been manually added to values, the TypeScript compiler looks at assigned values, their usage, and then infers the ‘missing’ types. In the example above, the parameters to the sum function have been type annotated, so the compiler can use those types and the return expression to infer that sum must return a number.

Structural typing

We use TypeScript for, well, types, so TypeScript clearly needs a system for determining what types values take.

There are two types of typed languages: nominal and structural.

With nominal typing, you explicitly name (hence ‘nominal’) a value/variable to be something, and the variables that will be type compatible with the value will be those named with the same type. This is how it works in languages like Rust.

TypeScript is structurally typed since it models JavaScript’s duck typing behaviour. The typing is structural in that the TypeScript compiler looks at the structures of values, and if two values share the same structure, they are type compatible. They key word is ‘share’, since type compatibility between two values is achieved even when those values structurally deviate otherwise.

Here’s an example to illustrate this:

interface Foo {
  requiredForFooAndBar: string
  optionalForFoo?: string
}

interface Bar {
  requiredForFooAndBar: string
  optionalForBar?: string
}

// oops, this was meant to be Foo
const foo: Bar = {
  requiredForFooAndBar: 'foo',
}

Even though Foo and Bar differ in their general shape, TypeScript is fine with this since both Foo and Bar share the requiredForFooAndBar property.

For the same reason, TypeScript cannot prevent the addition of extra properties on typed objects:

interface Foo {
  something: string
}

const shouldNotBeInFoo = {
  stringThing: 'text',
  booleanThing: true,
}

const foo: Foo = {
  // nothing in TS prevents this object
  // from being added
  ...shouldNotBeInFoo,
  something: 'something',
}

The object foo is of type Foo as long as it has the property something — i.e. it shares Foo’s structure.

Nominal typing in TypeScript

Structural typing offers flexibility, but as the examples point out, sometimes you crave a bit more type rigidity.

There is a way to sort of achieve nominal typing in TypeScript — through a technique called type ‘branding’, whereby you brand/tag a type with a property whose addition to a value determines type compatibility. Let’s modify the example from above:

interface Foo {
  requiredForFooAndBar: string
  optionalForFoo?: string
}

interface Bar {
  _brand: 'bar'
  requiredForFooAndBar: string
  optionalForBar?: string
}

// TS-ERROR: Property '_brand' is missing in type
// '{ requiredForFooAndBar: string; }' but required in type 'Bar'.
const foo: Bar = {
  requiredForFooAndBar: 'foo',
}

While it does not prevent anyone from adding the brand to any object, it can help in preventing mistakes in declarations.

There are plans to add nominal typing to TypeScript in the future.

Type erasure

No types are shipped with your TypeScript code to the browser. The types are stripped at compile time and what is left for the runtime is vanilla JavaScript since browsers do not understand TypeScript (for now, at least).

function sum(num1: number, num2: number) {
  return num1 + num1
}

// COMPILES DOWN TO:

function sum(num1, num2) {
  return num1 + num1
}

As a result, TypeScript:

  • types are not available at runtime — you cannot do things like ‘if(somType instanceof anotherType)’. Type compatibility checks can be done through e.g. property checks and tags.
  • has no effect on runtime performance (no extra bytes shipped)
  • has no effect on runtime behaviour

Conclusion

Type inference and erasure are what make TypeScript's adoption particularly appealing. With type erausre, you gain compile time type safety with no runtime overhead, and type inference goes a long way in reducing the need to bloat your code with type annotations.