Type Checking In TypeScript: A Beginners Guide

Jayson Lennon
Jayson Lennon
hero image

There's nothing worse than writing code, and then having it break due to a simple error.

code errors

The good news is that Typescript has several type checking techniques built-in, that enable us to catch errors at both compile-time and at runtime.

This not only makes our lives so much easier but also helps to enhance code quality and build more robust and maintainable applications.

It's a win:win situation.

The trick, of course, is knowing how to use these type checking features, and so that's what I'm going to show you today.

Sidenote: I'm going to assume if you're reading this article that you have some experience with either TypeScript or JavaScript.

However, if you struggle to understand some of the concepts covered here, how to apply them, or just want a deep dive into TypeScript, then check out my complete course on Typescript here.

learn typescript

It takes you from complete beginner, to building real-world applications and being able to get hired, so there's something for any level of TypeScript user.

Check it out above, or watch the first videos here for free.

With that out of the way, let's get into type checking and get everyone up to speed with some basics.

What are types and what is type checking?

Types and type checking in TypeScript is basically a way of getting around any issues that you might usually get with dynamic JavaScript.

Here's how it works.

Types

Every piece of data in TypeScript is given a "type", and this "type" determines what properties the data has, as well as what methods are available to it.

These types can be:

  • Number
  • String
  • Enum
  • Boolean
  • Any
  • Void
  • Never
  • Array
  • Object
  • Type assertions
  • Null
  • Undefined
  • Tuple, and
  • Let

The types that are assigned to data and functions are done in code through various mechanisms, but we'll cover that later.

Type checking

Because all of the data in TypeScript is given a type, this means that the compiler can check every property of that data to make sure it's being used correctly.

This is called type checking and it includes:

  • Making sure that the expected properties exist, and
  • That the methods and functions being used are all compatible

Pretty handy, but it gets better.

Because of these types in the code, it means that TypeScript can perform most of the type checking at compile time, giving you immediate feedback in your IDE, and a huge productivity boost!

typechecking benefits

When TypeScript uses types to perform compile time type checking, those types are classified as static types. However, there are different techniques to interface with TypeScript for varying degrees of type checking.

Some type checking is simple, such as checking if a section of data is a number or not. Other type checking can be more complicated, but it also allows for more complexity in rules.

For example

You could set it up so that you only allow logged-in users who are employees of the customer service department to gain access to a specific web page.

I'll go through this more as we work through this guide, but typically, more guarantees and complexity will require more effort and more code. That being said, you can still get substantial productivity gains and reduction in bugs with even just a small amount of type checking.

So let's look at a few type checking methods.

Compile time type checks

The first (and most important) stage for type checking in TypeScript are compile time type checks.

Here's how compile time checks work:

  • The compiler will analyze all the types used in your code and make sure the code is correctly using those types
  • If some code uses a type incorrectly, then a compiler error will occur and you'll know immediately that something is wrong
  • If the program doesn't pass compile time type checking, then chances are it won't run correctly

In order to leverage compile time type checking, it's important to understand some of the TypeScript techniques available for interfacing with the type checker.

We won't go into detail about all of the type checking techniques available, as that can get a little overwhelming. Instead, let's take a look at a sample of techniques, just so you can get an idea of what's possible.

Type aliases

Type aliases provide a way to give a name to some type, and are usually used to make code easier to read and to work with.

This can be something simple like assigning a new name to an existing type, or it can be more complex like defining the shape of an object.

For example

Imagine that we have a list of grocery items, like so:

const groceries: [string, number][] = [];

This code works, and TypeScript will guarantee that we have an array of tuples containing both string and number.

However, a problem occurs in that we're not exactly clear on what data we need for the grocery list.

The string could be a name like "tomato", but it could also be an ID like "P-51321", whereas number is probably the quantity, but what if it's the price instead?

unclear code

Pretty confusing right?

The good news is that we can improve this code by using type aliases to make it clear what data is required for this array of tuples.

type Name = string;
type Price = number;
const groceries: [Name, Price][] = [];

Now we can see that groceries should consist of a Name and a Price, and using this code won't leave us scratching our heads trying to figure out what to put in the string or number sections.

Type aliases for shape definition

As mentioned earlier, type aliases can also be used for defining the shape of objects.

With that in mind, we could create a type alias to define what a grocery item consists of:

type GroceryItem = {
  name: string,
  quantity: number,
  unitPrice: number,
};

const tomato: GroceryItem = {
  name: "Tomato",
  quantity: 3,
  unitPrice: 0.2,
};

Now that we have this alias, TypeScript will ensure that anytime we use the alias in our program, the data it refers to will always have the three properties we defined.

This has some implications:

  • We don't need to check if the properties are present in the object, because they are guaranteed to exist
  • The properties will always contain the data we defined, so we don't need to check the data in each property
  • We can write functions and just assume that the data exists and is the correct type, because TypeScript guarantees these
  • The program will have better performance because we don't need to check anything at runtime
  • The code is easier to work with because we can focus on implementation and just let TypeScript give us an error if we make a mistake

As you can see, this is all incredibly helpful.

For example

Here is how we can use our GroceryItem alias in a function:

function purchase(item: GroceryItem): number {
  // `item` always has a `quantity` and `unitPrice`, and they are always the `number` type, so we can perform the calculation without performing any checks at all.
  return item.quantity * item.unitPrice;
}

There's also other benefits to this method.

Added benefit: Type aliases help you to 'auto' document

I don't know about you but sometimes when you're writing functions, it feels like it should be obvious what kind of data should be passed to it, and what it should return.

However, that's only because we have knowledge of how it works, since we are writing it. The reality though, is that we'll probably forget about it in another hour or two as we move on to other things.

However, we can get around this when using type aliases.

How?

Because using type aliases for function parameters and return types can help indicate what data is required for the function, and what it returns, while also acting as a form of self-documenting code.

For example:

// Grocery example from the previous block, this time with a type alias for the return type:
type Cost = number;
function purchase(item: GroceryItem): Cost {
  return item.quantity * item.unitPrice;
}

// another example without type aliases
function search(user: string) {}

// using a type alias
type LoginName = string;
function search(user: LoginName) {}

// using a different type alias
type Email = string;
function search(user: Email) {}

No more trying to remember what data is needed!

However, aliases are not a perfect method for type checking...

The problem with aliases in type checking

Type aliases can make code significantly clearer, but there is one caveat when using type aliases, and it's that they are just aliases, and not a unique type.

This means that it's possible to accidentally mix up some data that passes type checking, but produces incorrect program behavior.

For example

Imagine that we have a function that prints out a full name from first and last name parts:

type FirstName = string;
type LastName = string;

function printName(first: FirstName, last: LastName) {
  console.log(`${first} ${last}`);
}

// ... many lines of code later ...

const first = "Emma";
const last = "Johnson";
printName(last, first);
// Whoops! We switched the first name and last name for this function call!
// OUTPUT: Johnson Emma

See what happened there? It switched the first and last name around.

However, even though this is the wrong order and a clear bug, TypeScript doesn't give us any type checking errors. This is because in theory, the types are correct, and so the program runs without crashing.

Obviously this isn't what we want, and type aliases aren't able to fix this issue because a string is always a string, regardless of how many aliases we create.

strings are strings

However, TypeScript is perfectly capable of making this bug impossible, we just need to tweak our code to use a different type.

Classes

Classes enable us to give a name to some data which is checked at compile time, similar to a type alias, but with one critical difference in that classes are their own unique type.

This means that if we used classes instead of type aliases in the printName function from the previous section, then we would get a compiler error when we mixed up the first and last names.

For example

Let's take a look at a revised version that uses classes instead:

// Class instead of type alias.
class FirstName {
  // We set the `name` property to `private`.
  private _name: string;

  // Boilerplate to set the _name property.
  constructor(name: string) {
    this._name = name;
  }

  // Retrieve the name.
  public get name() {
    return this._name;
  }
}

class LastName {
  private _name: string;
  constructor(name: string) {
    this._name = name;
  }
  public get name() {
    return this._name;
  }
}

Important: Setting the class properties to private is a vital step here, because TypeScript uses a structural type system.

This means that TypeScript looks at the shape of the data it has and compares it to the shape of the data it needs.

Since both classes have a name property, TypeScript would correctly identify that they have the same shape, if the properties were public. But by making the properties private, it hides them, so TypeScript isn't able to compare the shape of FirstName and LastName.

This is exactly what we need because we want the type checking to fail if we swap the first name and last name accidentally.

For example

Usage is similar to the previous example with aliases, except we need to instantiate the classes:

// `FirstName` and `LastName` refer to the classes written above.
function printName(first: FirstName, last: LastName) {
  console.log(`${first.name} ${last.name}`);
}

const first = new FirstName("Emma");
const last = new LastName("Johnson");

printName(first, last);
// OUTPUT: Emma Johnson

// Compiler error!
printName(last, first);

Boom! No more reverse name order.

Classes have many other features outside the scope of compile time type checks, so definitely check out the official handbook to learn more about their capabilities. (I also cover them more in my course content here.)

Interfaces

Interfaces offer a way to define the shape of data, similar to type aliases while also allowing you to declare the methods that must exist, like classes.

However, the actual implementation of the methods must be done on a class, as the interface declares only the signature of the methods and not the body.

The shape and methods of interface implementations are checked at compile time, so whatever data is using an interface will always have the correct shape and have the specified methods available.

We can take advantage of these facts by writing functions that operate on interfaces which frees us from having to perform any runtime checks.

For example

We can create a Logger interface that specifies that a log method exists:

// here is our interface
interface Logger {
  // log some message
  log(msg: string): void;
}

This log method has no implementation in the interface, so it's up to a class to implement it.

We can then create multiple implementations that use this interface and each one will be compatible with other code that uses the log method:

// A Logger implementation that logs to the terminal.
class TerminalLogger implements Logger {
  log(msg: string) {
    console.log(msg)
  }
}

// A Logger implementation that logs to a file.
import fs from 'fs';
class FileLogger implements Logger {
  constructor(public path: string) { }

  log(msg: string) {
    fs.writeFile(this.path, msg, (err) => {
      if (err) {
        console.error('Error writing to file:', err);
        return;
      }
    });
  }
}

Now that we have some implementations of the Logger interface, we can use both of them with the same function, like so:

// A free function to write a log somewhere.
function writeLog(logger: Logger, msg: string) {
  logger.log(msg);
}

// We make two different loggers:
const fileLogger = new FileLogger("sample.log");
const termLogger = new TerminalLogger();

// We can use `writeLog` with both loggers since they implement the Logger interface.
writeLog(fileLogger, "hello, file");
writeLog(termLogger, "hello, terminal");

Boom! If we tried to use writeLog with a class that doesn't implement the Logger interface, then the compile time type checking would fail and we wouldn't be able to compile our program.

Using interfaces provide strong type checking at compile time similar to classes, while also having a bit more flexibility similar to type aliases.

Runtime type checks

Not all situations are capable of being analyzed at compile time, so TypeScript has runtime type checking to handle this.

runtime checks

These checks are useful when you are working with data from an untrusted source, like an external API, and you need to check the data to ensure that it conforms to the types in your code.

Runtime type checking can also be used when you have multiple possible types in a union, but you don't know which you are working with until the program runs.

The most effective way to make use of runtime type checking is to create a multi-staged process that takes some data at runtime and assigns it to a known static type.

The process is broken into 3 steps:

  1. Receive some data at runtime
  2. Perform a runtime type check to assign it to a single static type
  3. Use the now known static type throughout your program

Step 3 is when compile time type checking takes over. Once the type is a known static type, it can then be used throughout the remainder of the program with support of the TypeScript compiler and IDE auto-completions.

Let's take a look at what options are available to perform runtime type checking.

typeof

The typeof operator provides basic support for performing a runtime type check.

However, it's a JavaScript operator, so it only operates on primitive JavaScript types. This means we can't use it directly to figure out a TypeScript type, but we can combine it with the methods discussed later in this post to get a TypeScript type.

Here is an example of how to use typeof:

function logType(data: any) {
  if (typeof data === 'string') {
    // `data` is a string, so we can use string methods:
    console.log(`${data.}`);

  } else if (typeof data === 'number') {
    // `data` is a number, so we can perform numeric operations:
    const answer = data + 2;
    console.log(`${data} + 2 = ${answer}`);

  } else if (typeof data === 'boolean') {
    // `data` is a boolean:
    const yesOrNo = data ? "yes" : "no";
    console.log(`${yesOrNo}`);

  } else {
    // We don't know what the data is
    console.log('Data is of an unknown type.');
  }
}

logType('Hello');
// output: HELLO

logType(10);
// output: 10 + 2 = 12

logType(true);
// output: yes

logType({});
// output: Data is of an unknown type.

instanceof

The instanceof operator gets us a little closer to identifying the types we are working with, as it can be used to figure out which class some data was instantiated from:

class Student { }
class Teacher { }

function logType(data: any) {
  if (data instanceof Student) {
    console.log('Data is an instance of Student.');
  } else if (data instanceof Teacher) {
    console.log('Data is an instance of Teacher.');
  } else {
    console.log('Data is not an instance of Student or Teacher.');
  }
}

const student = new Student();
const teacher = new Teacher();

logType(student);
// Output: Data is an instance of Student.

logType(teacher);
// Output: Data is an instance of Teacher.

logType({});
// Output: Data is not an instance of Student or Teacher.

But, since instanceof only applies to classes, we won't be able to use it with interfaces or type aliases.

Type predicates

typeof and instanceof only allow us to branch based on some criteria, and while it's technically possible to move the data around and eventually arrive at a static TypeScript type, it takes a lot of code and is difficult to maintain.

This is where type predicates come in.

Type predicates allow us to assign some runtime data to a static type. Once assigned, we get the benefits of TypeScript's compile time type checking.

In this example, we will use a news site that has blog posts and news articles:

interface BlogPost {
  title: string;
  content: string;
}

interface NewsArticle {
  headline: string;
  body: string;
  source: string;
}

To create a type predicate to determine which interface we are working with, we use the is keyword as part of the return type of a function.

This keyword tells TypeScript whether or not the input data is of a certain type:

// type predicate
function isNewsArticle(obj: any): obj is NewsArticle {
  // We use `in` to check for the existence of the fields needed for a news article:
  return ("headline" in obj
    && "body" in obj
    && "source" in obj);
}

function isBlogPost(obj: any): obj is BlogPost {
  return ("title" in obj && "content" in obj);
}

The type predicates return a boolean that indicates whether the data is the type we expect.

  • If the function returns true, then TypeScript will use the type indicated in the predicate, and we get the benefits of compile time type checking and IDE completions
  • If it's false, then TypeScript can't determine the correct type, and so it won't be able to perform compile time checking

Here is the remainder of the program that utilizes the type predicate:

function processPost(post: BlogPost | NewsArticle) {
  // Use type predicate to check if we have a blog post
  if (isBlogPost(post)) {
    // `post` is a `BlogPost` type and we get IDE and compiler support
    console.log(`Title: ${post.title}`);
    console.log(`Content: ${post.content}`);
    console.log("This is a blog post.");
  } else {
    // `post` is a `NewsArticledata type because the `BLogPost | NewsArticle` union guarantees that we have one or the other.
    console.log(`Headline: ${post.headline}`);
    console.log(`Body: ${post.body}`);
    console.log(`Source: ${post.source}`);
    console.log("This is a news article.");
  }
}

// make a blog post
const blogPost: BlogPost = {
  title: "Introduction to TypeScript",
  content: "TypeScript is a statically typed superset of JavaScript..."
};

// make a news article
const newsArticle: NewsArticle = {
  headline: "New Feature Release",
  body: "We are excited to announce the release of our latest feature...",
  source: "Tech News Weekly"
};

processPost(blogPost);
// Output:
// Title: Introduction to TypeScript
// Content: TypeScript is a statically typed superset of JavaScript...
// This is a blog post.

processPost(newsArticle);
// Output:
// Headline: New Feature Release
// Body: We are excited to announce the release of our latest feature...
// Source: Tech News Weekly
// This is a news article.

There are no restrictions on what kinds of data we can use type predicates with, and we can write them to operate on the any type, or on a union of types.

The goal is the same: narrow down a large set of types to just a single type which we can then use in the program.

Go test these out with your own code

As you can see, there are many more techniques available for compile time and runtime type checking in TypeScript, and each are incredibly helpful for reducing time spent looking for errors.

We've only covered the tip of the iceberg here, but I hope this small selection helped to illustrate the differences between the both compile and runtime checks, and gave you an idea of what's possible in TypeScript.

If you want to learn more, check out my complete TypeScript course here on Zero To Mastery, or watch the first videos for free.

Like I said earlier, it can take you from a total beginner with basic JS experience, to building large scale apps and being able to be hired as a TypeScript developer.

You'll not only get the course content, you'll also get direct access to me, as well as other devs who are all learning TS, in our private Discord server.

BONUS: More TypeScript tutorials, guides & resources

If you've made it this far, you're clearly interested in TypeScript so definitely check out all of my TypeScript posts and content:

More from Zero To Mastery

How To Ace The Coding Interview preview
How To Ace The Coding Interview

Are you ready to apply for & land a coding job but not sure how? This coding interview guide will show you how it works, how to study, what to learn & more!

How to Get More Interviews, More Job Offers, and Even a Raise preview
How to Get More Interviews, More Job Offers, and Even a Raise

This is Part 3 of a 3 part series on how I taught myself programming, and ended up with multiple job offers in less than 6 months!

[Guide] Computer Science For Beginners preview
[Guide] Computer Science For Beginners

You DO NOT need a CS Degree to get hired as a Developer. Learn Computer Sciences Basics today with this free guide by a Senior Dev with 10+ years of experience.