Typescript under the hood

Typescript under the hood

A look at how TypeScript works

So you've been using TypeScript for a while now and finally, you're wondering how it does what it does. The relationship between TypeScript and JavaScript is a pretty interesting one and one from which we can learn some interesting things about how programming languages work under the hood.

When most of us learn a technology, we learn the technology without learning the technology inside the technology. We learn how to use it without worrying about how it does what it does.

Recently, I've taken an interest in looking under the hood (essentially doing a code review of the tools I use). I've learned a lot through this. More than just about the specific technology I'll be looking at.

In this article, we'll do a high-level tour of how Typescript works under the hood and hopefully set the stage for more Typescript related articles in the future.

You might know all this. If you do it's great! Correct me where I go wrong and explain better where I'm unclear. If not, then it will be very useful. Hopefully, all of us will find something we didn't know.

Let's go!

Definition

TypeScript is an open-source language that builds on JavaScript by adding static type definitions and static type checking. These types provide a way to describe the shape of an object, providing better documentation, and allowing TypeScript to validate that your code works correctly. Writing types can be optional in TypeScript because it has powerful type inference which allows you to get a lot of power without writing additional code.

Being strongly typed on top of a weakly typed platform enables TypeScript to do some language syntax and implementation that would be difficult, if not impossible, to do in traditional strongly typed languages.

Why?

The reason for TypeScript's existence can be summarised with these three ideas.

  • Fail Fast, Fail Often: a philosophy that values extensive testing and incremental development to determine whether an idea has value. An important goal of the philosophy is to cut losses when testing reveals something isn’t working and quickly try something else, a concept known as pivoting. Achieving this means putting in systems that can speed up processes and allow for quick feedback.
  • Fail first: put the steps that cover the most likely or impactful failures first. If there is a part of the process that is likely to break frequently without someone noticing, test for that early in the process.
  • Confidence: The biggest and most important reason we test things is confidence. We want to be confident that the code we are writing for the future won't break the app that we have running in production today.

TypeScript allows for your code to fail as you're writing it. That way you have immediate feedback about what will not work and you don't have to discover it down the line. This gives you confidence when you ship that you're shipping the correct thing. We want to see things fail at this stage because of the following factors:

  • Cost: As you progress in the development process, testing things becomes more costly. This comes as actual money to run tests in a continuous integration environment, but also in the time it takes engineers to write and maintain each individual test.
  • Scope: The further in the process you go, the more potential points of failure there are and therefore, the more likely it is that a test will break, leading to more time needed to analyse and fix the failures.
  • Speed: As you move along in the process, the tests typically run slower. This is because the further along you are, the more code you have to test.

How TypeScript works under the hood

ts-under the hood.png

When you run a program, what you’re really doing is telling the runtime to evaluate the byte-code generated by the compiler from the AST parsed from your source code. The details vary, but for most languages, this is an accurate high-level view. Where TypeScript is special is that instead of compiling straight to byte code, it compiles to JavaScript code! You then run that JavaScript code like you normally would—in your browser, or with NodeJS, etc.

So when does it make code type-safe? After the TypeScript compiler generates an AST for your program—but before it emits the code—it type checks your code.

typescript compiler.png

In this process, steps 1–2 use your program’s types; step 3 does not. TypeScript’s type system is fully erased.

That’s worth reiterating: when the typescript compiler compiles your code from TypeScript to JavaScript, it won’t look at your types. That means your program’s types will never affect your program’s generated output and are only used for type checking. This feature makes it foolproof to experiment with, update, and improve your program’s types, without risking breaking your application.

This also means that all valid JavaScript code is valid TypeScript code. This is important because it means you can run the TypeScript compiler over JavaScript and get TypeScript's fantastic auto-complete without using any types at all. In fact, the TypeScript team handles a lot of the editor integration for TypeScript, which allows us to get a consistent experience across many editors. Note that TypeScript's editor integration supports JavaScript, so it's likely that if you're using JavaScript, you're already using TypeScript under the hood.

Structural Type System

One of TypeScript’s core principles is that type checking focuses on the shape that values have. This is sometimes called “duck typing” or “structural typing”. Before we explore the characteristics of structural typing, let's talk a bit about C# and Java for comparison.

In C# any Java—which use reified, nominal type systems—any value or object has one exact type - either null, a primitive, or a known class type. The definition of this type will reside in a class somewhere with some name, and we can’t use two classes with similar shapes in place of each other unless there’s an explicit inheritance relationship or commonly implemented interface. The types we wrote in the code are present at runtime, and the types are related via their declarations, not their structures.

In TypeScript—which uses structural typing—it’s better to think of a type as a set of values that share something in common. Because types are just sets, a particular value can belong to many sets at the same time. TypeScript provides several mechanisms to work with types in a set-like way, for example, unions and intersections, and you’ll find them more intuitive if you think of types as sets.

In TypeScript, objects are not of a single exact type. For example, if we construct an object that satisfies an interface, we can use that object where that interface is expected, even though there was no declarative relationship between the two.

interface Pointlike {
  x: number;
  y: number;
}
interface Named {
  name: string;
}

function logPoint(point: Pointlike) {
  console.log("x = " + point.x + ", y = " + point.y);
}

function logName(x: Named) {
  console.log("Hello, " + x.name);
}

const obj = {
  x: 0,
  y: 0,
  name: "Origin",
};

logPoint(obj); // ✅
logName(obj); // ✅

The obj variable is never declared to be a Pointlike or Named type. However, TypeScript compares the shape in the type-check. They have the same shape, so the code passes. TypeScript’s type system is also not reified, that is there’s nothing at runtime that will tell us that obj is Pointlike. In fact, the Pointlike type is not present in any form at runtime.

There is also no difference between how classes and objects conform to shapes:

class VirtualPoint {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

const newVPoint = new VirtualPoint(13, 56);
logPoint(newVPoint); // logs "13, 56"

If the object or class has all the required properties, TypeScript will say they match, regardless of the implementation details.

OOP programmers are often surprised by two particular aspects of structural typing. The first is that the empty type seems to defy expectation:

class Empty {}

function fn(arg: Empty) {
  // do something
}

fn({ k: 10 }); // ✅ No error, but this isn't an 'Empty'

TypeScript determines if the call to fn here is valid by seeing if the provided argument is a valid Empty. We can see that { k: 10 } has all the properties that Empty does because Empty has no properties. Therefore, this is a valid call!

The second frequent source of surprise comes with identical types:

class Car {
  drive() {
    // hit the gas
  }
}
class Golfer {
  drive() {
    // hit the ball far
  }
}

let w: Car = new Golfer(); // ✅ No error?

Again, this isn’t an error because the structures of these classes are the same.

Will it last?

Should you invest the time to understand TypeScript deeply? Won't it lose favour like flow, CoffeeScript, etc?

The underlying tension to all this is the divergence between TypeScript and JavaScript. Typescript pitches itself today as JavaScript with types, i.e., if JavaScript evolves, then TypeScript evolves in the same way. And that's the biggest argument for why it's likely going to stay for long.

The TypeScript team contributes to the TC39 committees, which help guide the evolution of the JavaScript language. For example, the TypeScript team championed proposals like Optional Chaining, Nullish coalescing Operator and Throw Expressions. And more recently have submitted a proposal to officially add types to JavaScript.

Adopting TypeScript is not a binary choice. There's a sliding scale of TypeScript support that you can get in any project. You can start by annotating existing JavaScript with JSDoc, then switch a few files to be checked by TypeScript and over time prepare your codebase to convert completely.

Finally, TypeScript’s type inference means that you don’t have to annotate your code until you want more safety.

Not convinced? Check out TJ VanTollon's take on this talk:

Speaking of gradually adopting TypeScript. Want to know how you can do that? Keep an eye out for my next blog post for a walkthrough.

Did you find this article valuable?

Support Bhekani Khumalo by becoming a sponsor. Any amount is appreciated!