There's nothing worse than writing code, and then having it break due to a simple error.
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.
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.
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.
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:
The types that are assigned to data and functions are done in code through various mechanisms, but we'll cover that later.
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:
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!
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.
The first (and most important) stage for type checking in TypeScript are compile time type checks.
Here's how compile time checks work:
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 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?
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.
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:
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.
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...
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.
However, TypeScript is perfectly capable of making this bug impossible, we just need to tweak our code to use a different type.
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 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.
Not all situations are capable of being analyzed at compile time, so TypeScript has runtime type checking to handle this.
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:
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.
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.
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.
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.
true
, then TypeScript will use the type indicated in the predicate, and we get the benefits of compile time type checking and IDE completionsfalse
, then TypeScript can't determine the correct type, and so it won't be able to perform compile time checkingHere 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.
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.
If you've made it this far, you're clearly interested in TypeScript so definitely check out all of my TypeScript posts and content: