Learn TypeScript with Me!

August 10, 2023

What's the motivation of writing this blog?

I've been using TypeScript for a lot of my projects, but I want to really study TypeScript in-depth to solidify my programming skills. On learning new things, I usually just watch the videos and barely take notes. This blog is specifically made for me to make sure I understand the concepts that I have learned.

This is based on Frontend Masters' TypeScript Fundamentals v3.

Why TypeScript?

It's mostly for safety and linting especially if you work in a good IDE like VS Code. It helps development by making sure things work as intended as best as it could (do checks at compile time to avoid errors at run time).

TypeScript is composed by three things: the language, the language server (basically the thing that allows VS Code to show you suggestions or warn you when something goes wrong), and the compiler.

The compiler compiles your TypeScript code into JavaScript code that browsers can run. What is cool is that you can specify the JavaScript version you want to target! For example, if you're targetting older browsers, you can compile in ES2013, if you want it to use async-await syntax, you can compile it in ES2016. You can also change the settings of how modules are compiled, i.e. change from the ES2016 modules to CommonJS modules which Node supports.

In this course, I learned some things you should do when you write TypeScript code:

(1) When you initialize variables, it is not necessary to specify the variable type. The tutor even said that he does not like explicitly stating types when it's obvious like when you write let number = 5;

(2) There are times where you need to declare the variable first without giving it a value. This is the time where you want to explicitly state what type you want it to be.

(3) Often, in development, you'll fight hurdles like where you need to generalize the types as development goes on. You do this by making making a more general declaration beforehand.

(4) Always try to explicitly state the return value of functions.

Objects, arrays, and tuples

Objects

Here are some example to kickstart your understanding on how to type objects.

1
let myCar: {
2
year: number;
3
maker: string;
4
name: string;
5
} = {
6
year: 2002,
7
maker: "Honda",
8
name: "Civic",
9
};

If you want an object have many keys but they all have the same kind of value, you can use something called index signature.

1
let phonebook: {
2
[k: string]: string;
3
};

This allows you to do stuffs like

1
phonebook.myself = "+62 812 812 812";
2
phonebook.mom = "+62 812 812 813";

Arrays and tuples

For arrays, it's quite simple:

1
let stringArray: string[];

If you want to make a tuple, you have to explicitly state it as a tuple.

1
// This is the correct way to do it:
2
let myCar: [number, string, string] = [2002, "Honda", "Civic"];
3
4
// And this is the wrong way to do it:
5
let myCar = [2002, "Honda", "Civic"];
6
// In the above example, myCar has a type of (number | string)[]!

A step back: What is type checking anyways?

In the programming world, type checking is evaluations done to see if the type of something matches a certain condition.

This could be done when you assign variables,

1
x = y; // Is x and y the same type?

or when you call functions,

1
x = f(); // Is x and the return type of f the same type?

... and many more. That is called type evaluation.

Static typing and dynamic typing

In a statically typed programming language, type checkings are done at compile time, not run time. This is what TypeScript does.

This is different than, let's say, Python, which is a dynamically typed programming language by default:

1
my_number = input("Please give me a number!")
2
print(f"Your number, divided by 5, is {my_number / 5}")

That code above will crash after you entered a number because when my_number / 5 is evaluated at run time by Python, it says to you, "Hey, you can't give me a string and expect me to divide it by 5!"

The term duck typing you often see JavaScript called is just another name for dynamic typing.

Nominal typing and structural typing

Nominal typing refers to checks to see whether something has a type with the exact name, such as in Java.

1
class Car {
2
private int year;
3
private String name;
4
}
5
6
class Truck {
7
private int year;
8
private String name;
9
}
10
11
void printCar(Car x) {
12
// ...
13
}

If you put a Truck inside the printCar function, it will reject because it's expecting a Car, not a Truck.

Nominal typing is not how TypeScript behaves.

TypeScript behaves using structural typing -- it checks whether the structure is the same or not.

In the previous example,

1
class Car {
2
year: number;
3
name: string;
4
}
5
6
class Truck {
7
year: number;
8
name: string;
9
}
10
11
const randomObject = {
12
year: 1990,
13
name: "Ronald",
14
};
15
16
function printCar(car: Car) {
17
// ...
18
}

If you try to put a Car, Truck, or that randomObject to printCar, every single one of them will be accepted because they all share the same accepted structure of Car.

Union types

A union type is essentially like an 'OR' in math - if you have Type1 | Type2 it means it can either be Type1 or Type2. It's quite simple.

The one I want to highlight is how you can use tagged union types for cleaner code. Here is an example function that you might want to write:

1
const fetch = (): {name: string} | Error {
2
// Do something
3
}

The function fetch either returns an object with structure {name: string} or an Error. While this is fine, processing it would be hard, i.e. you'd have to do something like this:

1
const x = fetch();
2
3
if (x instanceof Error) {
4
// Handle error
5
} else {
6
console.log(x.name);
7
}

The portion, x instanceof Error is what you can call as a type guard.

You can make the code more readable by introducing tags and returning it as a tuple with the tag on the 0th index.

1
const fetch = (): ["success", {name: string}] | ["error", Error] {
2
// Do something
3
}
4
5
const x = fetch();
6
7
if (x[0] === "success") {
8
// Handle success case
9
} else {
10
// Handle error case
11
}

Intersection types

Here's where things get quite interesting. Intersections might confuse you, but it's helpful to think it as a venn diagram where the intersection is a place where both properties exists.

So, if you have, say,

1
type A = {
2
name: string;
3
};

and

1
type A = {
2
age: number;
3
};

The type, A & B (intersection of A and B) would be

1
type IntersectionofAAndB = {
2
name: string;
3
age: number;
4
};

because it contains both properties! (name from A and age from B)

Interfaces

In TypeScript, there are also interfaces. Sure, you can use them like a type, but it's much more strict. You cannot use things like union types or intersection types, you can only define an interface as an object.

1
interface AllowedInterface {
2
name: string;
3
age: number;
4
}
5
6
// Can't have an interface that is a union or intersection!
7
8
const myObject: AllowedInterface = {
9
name: "John",
10
age: 12,
11
};

Having an interface is useful if you want give a 'contract' to classes to make sure they implement something.

1
interface CuteStuff {
2
voice: string;
3
}
4
5
class Dog implements CuteStuff {
6
// This Dog class must implement the voice property.
7
}

You can have two definitions of an interface, too. It will work 'like' an intersection type.

For types, this will crash:

1
type A = {
2
name: string;
3
};
4
5
// This line below here will output an error because you have two definitions of A
6
type A = {
7
age: number;
8
};

But, for intersections, it will not!

1
interface A = {
2
name: string;
3
}
4
5
interface A = {
6
age: number;
7
}
8
9
let myObject: A;
10
11
/**
12
* myObject now has to comply with type {name: string; age: number;}
13
*/

When to use types or interfaces?

Use types if you want to use union/intersection types.

Use interfaces if you want to 'create contracts' for classes or you want the users of your code to be able to add properties to it.

Function types

You can define the signature of a callable type (i.e. functions) using this kind of syntax:

1
/**
2
* Using ':' to specify return type
3
*/
4
interface MyFunction {
5
(a: number, b: number): number;
6
}
7
8
/**
9
* Using '=>' to specify return type
10
*/
11
type MyFunction2 = (a: number, b: number) => number;
12
13
const add: MyFunction = (a, b) => a + b;

The return type void

In JavaScript, we know that functions that returns nothing will return undefined.

1
/**
2
* Output: undefined
3
*/
4
console.log(console.log(4));

In TypeScript, if you want to accept that a function you expect to not return anything, do not tell that it returns undefined, instead say that it returns void.

Returning undefined means that it will expect you to return undefined specifically but returning void means to ignore whatever the return type is.

Example:

1
function handleNumber(x: number, handler: (x: number) => void);
2
function handleNumber2(x: number, handler: (x: number) => undefined);

If you pass a handler that returns something other than undefined, handleNumber will work just fine, but handleNumber2 will output an error.

Function overloading

This is a bit complex, but let's say you have a scenario like this:

1
type NumberHandler = (x: number) => number;
2
type StringHandler = (x: string) => string;
3
4
function myHandler(x: number | string, action: NumberHandler | StringHandler) {
5
// Do something here
6
}

Say, you want the action to be NumberHandler only if x is a number and StringHandler only if x is a string.

To make this possible, you need something called function overloading.

1
type NumberHandler = (x: number) => number;
2
type StringHandler = (x: string) => string;
3
4
/**
5
* Overload functions by specifying the correct signatures.
6
*/
7
function myHandler(x: number, action: NumberHandler);
8
function myHandler(x: string, action: StringHandler);
9
/**
10
* Now, put the actual implementation below.
11
*/
12
function myHandler(x: number | string, action: NumberHandler | StringHandler) {}

One thing to make sure is that the signatures in the implementation must accommodate all the signatures you specified when you overload it.

Working with classes

There is a problem in JavaScript where you can't really know what things you have in a class:

1
class Person {
2
constructor(name, age) {
3
this.name = name;
4
this.age = age;
5
}
6
}
7
8
/**
9
* What properties does a Person have?
10
* What type is name?
11
* What type is age?
12
*/

Using TypeScript, you can make it clearer!

1
class Person {
2
name: string;
3
age: string;
4
constructor(name: string, age: string) {
5
this.name = name;
6
this.age = age;
7
}
8
}

Access modifiers

I assume you readers have learnt in your OOP class about these three fields: public, private, and protected.

TypeScript allows you to do that -- but bear in mind that this is only works in the linter world, at runtime it's still pretty much exposed.

1
class Person {
2
private name: string;
3
private age: string;
4
constructor(name: string, age: string) {
5
this.name = name;
6
this.age = age;
7
}
8
}
9
10
/**
11
* Now, name and age can only be seen inside of the Person class!
12
*/

Shorthand for constructing attributes

To avoid writing a lot of duplicate this.<var_name> assignments, you can do things like this in TypeScript:

1
class Person {
2
constructor(private name: string, private age: string) {}
3
}
4
5
/**
6
* This is basically the same code as the previous code block.
7
*/

Readonly attributes

You can define something that behaves like a constant defined using const for a class with readonly, i.e.

1
class Person {
2
private readonly name;
3
private age string;
4
constructor(name: string, age: string) {
5
/**
6
* You can assign name here because here is where you give it's initial value
7
*/
8
this.name = name;
9
this.age = age;
10
}
11
12
public changeName(name: string) {
13
/**
14
* This function is not valid because name is readonly, you can't reassign it
15
*/
16
this.name = name;
17
}
18
}

Top values

Let's discuss about these two types: any and unknown. They're similar in a way that they accept any value.

1
/**
2
* This is fine to do
3
*/
4
let x: any = 5;
5
x = "Hello";
6
x = [1, 5, 7];
7
8
/**
9
* And so this is too!
10
*/
11
let y: unknown = 5;
12
y = "Hello";
13
y = [1, 5, 7];

The difference happens when you try to actually use the variable.

1
/**
2
* TypeScript will allow this!
3
*/
4
let x: any = 5;
5
x.someRandomProperty.doSomething();
6
7
/**
8
* But, it will not allow this to happen.
9
*/
10
let y: unknown = 5;
11
y.someRandomProperty.doSomething();

You actually have to use type guards when using unknown, i.e.

1
let y: unknown = "Hello World!";
2
3
if (typeof y === "string") {
4
// You now can use y as if it was a string.
5
}

When to use them?

Generally, you'd use any in situations where you are moving from JavaScript to TypeScript incrementally. You'll move gradually from the easier types first and leave the harder types on any and work on it incrementally.

unknown, meanwhile is quite useful when you get a return response, say from an API. It could be anything (i.e. an error response or a successful response), so you'd want to check it first with type guards before processing it.

Bottom values

There is a type called never in TypeScript. It means that value cannot hold anything. Why is this useful?

Bottom values are useful when you absolutely want to check if your conditionals are exhaustive. Let's take a look at this example.

1
type Vehicle = Car | Truck;
2
3
const myVehicle: Vehicle = randomVehicle();
4
5
if (myVehicle instanceof Car) {
6
// Do something as a car
7
} else if (myVehicle instanceof Truck) {
8
// Do something as a truck
9
} else {
10
// This block should and will never be reached!
11
const neverData: never = myVehicle;
12
}

In the last block, neverData will have a type of never. How is this useful?

Let's say as your platform grows, you might add more vehicles later, e.g. you'll change the type Vehicle to Car | Truck | Boat:

1
type Vehicle = Car | Truck | Boat;
2
3
const myVehicle: Vehicle = randomVehicle();
4
5
if (myVehicle instanceof Car) {
6
// Do something as a car
7
} else if (myVehicle instanceof Truck) {
8
// Do something as a truck
9
} else {
10
/**
11
* This should be the boat case, but you're stating it as a never!
12
* Because it shouldn't be a never, TypeScript will give you an error to handle the case.
13
*/
14
const neverData: never = myVehicle;
15
}

This way, TypeScript helps you to handle cases that you might've forgotten when you add new stuffs along the way.

Type guards

There's a lot of way you can check for types, i.e.

1
type MyType = undefined | string | Date | { name: string };
2
3
let val: MyType = getRandomValue();
4
5
if (val === undefined) {
6
// val is undefined
7
} else if (typeof val === "string") {
8
// val is a string
9
} else if (val instanceof Date) {
10
// val is a Date
11
} else if ("name" in val) {
12
// val is a type of { name: string }
13
} else {
14
// val is in the type of 'never', this block shouldn't be reached
15
}

But say, you have a more complex type and want to create a function that checks for that certain type. There's two ways to do this.

Using is

You can assure TypeScript that something is a type of something by using the keyword is:

1
type Person = {
2
name: string;
3
age: number;
4
};
5
6
function isPerson(personLike: any): personLike is Person {
7
return (
8
personLike &&
9
"name" in personLike &&
10
typeof personLike["name"] === "string" &&
11
"age" in personLike &&
12
typeof personLike["age"] === "number"
13
);
14
}

(yes, the checking is a bit tedious, but we've to deal with it)

Doing it this way, you must be absolutely sure that what you write inside the condition is right.

Using asserts is

Using is doesn't guarantee you are free from runtime errors. At times, you might want to throw errors if the actual thing is not a person at runtime. You can use this keyword, i.e.

1
type Person = {
2
name: string;
3
age: number;
4
};
5
6
function assertIsPerson(personLike: any): asserts personLike is Person {
7
if (
8
!(
9
personLike &&
10
"name" in personLike &&
11
typeof personLike["name"] === "string" &&
12
"age" in personLike &&
13
typeof personLike["age"] === "number"
14
)
15
) {
16
throw new Error(`Assertion failed: ${personLike} isn't a Person!`);
17
}
18
}

Nullish values

You can use the !. operator to tell TypeScript that you're certain that something has the property of something. Let's take a look at an example.

1
type Cart = {
2
fruits?: string[];
3
vegetables?: string[];
4
};
5
6
const emptyCart: Cart = {};
7
8
/**
9
* This line below will output an error in TypeScript.
10
* This is due to the fact it's not sure whether emptyCart.fruits is actually there or not.
11
*/
12
emptyCart.fruits.push("Apple");
13
14
/**
15
* But, this will do fine.
16
* It tells TypeScript that you're absolutely sure that fruits is actually there.
17
*/
18
emptyCart.fruits!.push("Apple");

It's not a good idea to do this, though. You'd probably better off with something like this:

1
type Cart = {
2
fruits?: string[];
3
vegetables?: string[];
4
};
5
6
const emptyCart: Cart = {};
7
8
if (emptyCart.fruits) {
9
// Better use type guards to prevent runtime errors!
10
emptyCarts.fruits.push("Apple");
11
}

Generics

Use generics on cases where you want a function to accept anything and return anything without you losing the helpful type inference. Let's take a look at an example where you want to create a function that transforms a list of something into dictionaries.

1
type Car = {
2
id: number;
3
year: number;
4
name: string;
5
};
6
7
const cars: Car[] = [
8
{
9
id: 1,
10
year: 2002,
11
name: "Honda Civic",
12
},
13
{
14
id: 2,
15
year: 2020,
16
name: "Lamborghini Urus",
17
},
18
];
19
20
type Dict = {
21
[k: string]: any;
22
};
23
24
function listToDict(list: any[], idSetter: (item: any) => string): Dict {
25
const dict: Dict = {};
26
27
for (const x of list) {
28
dict[idSetter(x)] = x;
29
}
30
31
return dict;
32
}
33
34
console.log(listToDict(cars, (car) => car.id.toString()));

There's just one problem: you're having a lot of any here. When you access dict.carId.maker you won't be warned because it assumes that dict.carId is a type of any, not a type of Car.

Generics comes to save your day! Here's an example how you'd use them:

1
type Car = {
2
id: number;
3
year: number;
4
name: string;
5
};
6
7
const cars: Car[] = [
8
{
9
id: 1,
10
year: 2002,
11
name: "Honda Civic",
12
},
13
{
14
id: 2,
15
year: 2020,
16
name: "Lamborghini Urus",
17
},
18
];
19
20
type Dict<T> = {
21
[k: string]: T;
22
};
23
24
function listToDict<T>(list: T[], idSetter: (item: T) => string): Dict<T> {
25
const dict: Dict<T> = {};
26
27
for (const x of list) {
28
dict[idSetter(x)] = x;
29
}
30
31
return dict;
32
}
33
34
const carDict = listToDict(cars, (car) => car.id.toString());
35
console.log(carDict);

Now, when you try to access, let's say, carDict[1]., you'll be presented with only [id, year, name] to select. It now sees that each key holds a type of Car!

The <T> is what you'd call a type parameter. It allows you to parameterize any type you'd like to accept so it doesn't lose it's typing at the end (unlike when you use any).

Giving constraints to your generic

Let's say you want for sure to be certain that you only want to accept all types that have the property id in it. In other words, you want to make sure that T has a property of id in it.

To do so, you can use the extends keyword for your type parameter!

1
type HasId = {
2
id: number;
3
};
4
5
type Car = {
6
id: number;
7
year: number;
8
name: string;
9
};
10
11
const cars: Car[] = [
12
{
13
id: 1,
14
year: 2002,
15
name: "Honda Civic",
16
},
17
{
18
id: 2,
19
year: 2020,
20
name: "Lamborghini Urus",
21
},
22
];
23
24
type Dict<T> = {
25
[k: string]: T;
26
};
27
28
function listToDict<T extends HasId>(list: T[]): Dict<T> {
29
const dict: Dict<T> = {};
30
31
for (const x of list) {
32
dict[x.id.toString()] = x;
33
}
34
35
return dict;
36
}
37
38
const carDict = listToDict(cars);
39
console.log(carDict);

Now, when you access T, you can be sure that T will have an id in it -- no more need to create an idSetter function like in other examples!

This marks the end of this post. Thank you for reading, hope you find it useful!