Advance TypeScript: Take Your Development to the Next Level
Get familiar with some of Typescript's greatest advanced features.
Table of contents
By now, we've learned about most of the types and concepts in TypeScript that are vital when working with medium-to-complex applications.
The goal of this article is to explore the more complex and advanced types and concepts used in TypeScript applications. We'll discuss intersection types, type guards, discriminated unions, typecasting, index properties, function overloading, optional chaining, and nullish coalescing.
INTERSECTION TYPE:
DEFINITION & EXAMPLE:
An intersection allows us to combine multiple types and interfaces into one single type
(not interface). To create an intersection type, use the &
operator as follows:
type Name = {
name: string;
};
type Age = {
age: number;
};
type Person = Name & Age;
In the same way, let's create a new type
by combining two interfaces
:
interface Name {
name: string;
}
interface Age {
age: number;
}
type Person = Name & Age;
let p: Person = {
name: "John",
age: 18,
};
According to the above code, the Person
type must contain mandatory properties of both the Name
and Age
interfaces.
If you miss any of the interface mandatory properties, you'll get a compilation error.
An interface CANNOT be created by intersecting two or more
types
orinterfaces
.
DIFFERENCE BETWEEN UNION & INTERSECTION TYPES:
In Typescript, unions and intersections are defined in the Advanced Types section.
The main difference between the two is that an
intersection type
combines multiple types into one. Whereas, aunion type
refers to a value that can be one of the multiple types.The
&
symbol represents anintersection
, while the|
symbol represents aunion
.Those who have a background in programming may notice that these symbols are usually associated with logical expressions. The intersection (
&
) can be considered as anAND
whereas the union (|
) can be considered as anOR
.
To better understand the difference, let's look at an example:
interface FlyingAnimal {
flyingSpeed: number;
}
interface SwimmingAnimal {
swimmingSpeed: number;
}
function getSmallPet(): FlyingAnimal | SwimmingAnimal {
return {
flyingSpeed: 1,
};
}
function getFlyingFish(): FlyingAnimal & SwimmingAnimal {
return {
flyingSpeed: 1,
swimmingSpeed: 1,
};
}
In this example, we have two interfaces: FlyingAnimal
and SwimmingAnimal
. Furthermore, we have two functions getSmallPet
with a return type of FlyingAnimal | SwimmingAnimal
(union type) and getFlyingFish
with a return type of FlyingAnimal & SwimmingAnimal
.
Since the getSmallPet
return type is union, you should return all the mandatory properties of at least one of the types/interfaces included in the union.
Thus, we returned { flyingSpeed: 1 }
, which is a property of the FlyingAnimal
interface.
On the other hand, in intersection type
, you need to return all the mandatory properties of every type/interface included in the intersection.
Therefore, we have returned { flyingSpeed: 1, swimmingSpeed: 1 }
, if we miss any of the mandatory properties in getFlyingFish
, we will receive the following compilation error:
WHEN TO USE IT?
Instead of using intersections, we can just combine all the properties of the two types/interfaces that we want to intersect.
However, we may have two interfaces that are used separately as types somewhere in the program. To create a new type composed of the properties of these interfaces, we will intersect
them rather than rewrite their properties.
let's take an example to understand this:
interface I1 {
a: number;
}
interface I2 {
b: number;
}
let i1: I1 = {
a: 1,
};
let i2: I2 = {
b: 2,
};
let combinedI: I1 & I2 = {
a: 1,
b: 2,
};
There are two interfaces I1
and I2
that are used independently in variables i1
and i2
.
Additionally, we have the combinedI
variable, which has an intersection type of both I1
and I2
.
So instead of creating a new type/interface for the combinedI
with properties a
and b
, we just intersect the available interfaces I1
and I2
.
TYPE GUARDS:
A Type Guard reduces the type of an object in a conditional block. Basically, it is an additional piece of code we write to prevent errors at runtime
Some of the most famous Type Guards are
typeof
,in
, andinstanceof
.
typeof
OPERATOR:
In our previous blog Everyday Types in TypeScript, we looked at how type guards could be used to add type-checking to avoid compilation errors.
To understand it better, let's look at some more examples:
type alphanumeric = string | number;
function add(a: alphanumeric, b: alphanumeric) {
if (typeof a === "number" && typeof b === "number") {
return a + b;
}
if (typeof a === "string" && typeof b === "string") {
return a.concat(b);
}
throw new Error("Invalid parameters"); // return when a and b are not of the same type
}
In this example, we have a union-type alphanumeric
, which can be either string
or number
. Also, we have a simple add
function that has two parameters a
and b
of the type alphanumeric
.
In it, we add a type guard for checking whether both parameters have the same types, and then we perform some operations.
if (typeof a === "number" && typeof b === "number") {
return a + b;
}
This block checks whether parameters a
and b
are number
types. If they are, we will add them.
if (typeof a === "string" && typeof b === "string") {
return a.concat(b);
}
This block checks whether parameters a
and b
are string
types. If they are, we will concatenate them.
instanceof
OPERATOR:
As discussed in our previous blog on Classes in TypeScript, the class
can be used as a valid TypeScript type.
The instanceof
operator can be used to check whether a variable is an instance of a class.
Here's an example to help you understand:
class Car {
drive() {
console.log("Driving a car...");
}
}
class Truck {
drive() {
console.log("Driving a truck...");
}
loadCargo() {
console.log("Loading cargo on truck...");
}
}
type Vehicle = Car | Truck;
function useVehicle(v: Vehicle) {
v.drive();
v.loadCargo(); // we get compilation error here
}
We have two classes in the above code: Car
and Truck
. Both classes have a common method called drive()
, while class Truck
has an additional method called loadCargo()
.
Then we are creating a new type called Vehicle
, which is a union of Car
and Truck
.
Hence, the Vehicle
type may or may not have all the properties of both Car
and Truck
.
Furthermore, we have a function useVehicle
which accepts a parameter v
of type Vehicle
, and inside it, we access the methods drive()
and loadCargo()
.
However, we are unable to access the loadCardo()
method due to a compilation error. Take a look at the following picture:
This is because the drive()
method is available in both Car
and Truck
classes, but the loadCargo()
method is only available in the Truck
class. Since the Vehicle
is a union type, there is a chance that you could pass a parameter of type Car
that does not have the loadCargo()
method.
To avoid this error, we can create a type guard by using the instanceof
keyword:
function useVehicle(v: Vehicle) {
v.drive();
if (v instanceof Truck) { // type guard
v.loadCargo(); // runs without error
}
}
This means that only if v
is an instance of the class Truck
, execute the next line of code.
in
OPERATOR:
The in
operator performs a safety check on the existence of a property in an object
. This can also be used as a type guard
. For instance:
interface Developer {
developmentTools: string[];
}
interface Tester {
testingTools: string[];
}
type Employee = Developer | Tester;
function getToolsUsed(employee: Employee) {
console.log(employee.developmentTools); // compilation errors
console.log(employee.testingTools); // compilation errors
}
let developer: Employee = {
developmentTools: ["typescript", "react"],
};
We have two interfaces, Developer
and Tester
, with both having the developmentTools
and testingTools
properties of type string[]
respectively.
After that, we declare a new type Employee
that is a union type.
We also have a function getToolsUsed
that accepts a parameter employee
of type Employee
.
We thus encounter a compilation error when we try to access a developmentTools
or a testingTools
through the employee
parameter because that object may or may not have those properties.
We can prevent this by adding a type guard
using the in
operator, as follows:
function getToolsUsed(employee: Developer | Tester) {
if ("developmentTools" in employee) {
// only works if the employee has the property developmentTools
console.log(employee.developmentTools); // runs without error
}
if ("testingTools" in employee) {
// only works if the employee has the property testingTools
console.log(employee.testingTools); // runs without error
}
}
DISCRIMINATED UNION:
Discriminated union type guard is a design pattern you use to ensure a runtime error won't occur because the property that doesn't exist is accessed.
As an example, let's replace in
in the above example with a discriminated union-type guard.
interface Developer {
role: "developer";
developmentTools: string[];
}
interface Tester {
role: "tester";
testingTools: string[];
}
let Employee: Developer | Tester;
function getToolsUsed(employee: Developer | Tester) {
switch (employee.role) {
case "developer":
console.log(employee.developmentTools); // valid code
break;
case "tester":
console.log(employee.testingTools); // valid code
break;
}
}
To all of the interfaces that are included while creating a union type
, we are assigning a role
property - a literal type
(click here for more information about literal types).
Lastly, we used the role
property as a differentiator in switch-case statements.
INDEX PROPERTIES:
Index signatures look similar to property signatures, but with one difference. Instead of writing the property name, you simply place the type of key within square brackets.
SYNTAX AND DECLARATION:
Take a look at the following code:
interface StringObject {
[key: string]: string;
}
const strings: StringObject = {
en_US: "Hello, World!",
fr_FR: "Bonjour, le monde!",
es_ES: "¡Hola, mundo!",
de_DE: "Hallo, Welt!",
};
Here we have a StringObject
interface with a string
key
and string
value
structure. We will receive the following compilation error if we break the rules:
USE CASE OF INDEX SIGNATURE:
The purpose of index signatures is to type objects of unknown structure when only key and value types are known.
To understand this, let's look at an example.
interface SalaryStructure {
[key: string]: number;
}
let developerSalary: SalaryStructure = {
baseSalary: 10000,
bonus: 100,
incentive: 50,
appreciationBonus: 10,
};
const getTotalSalary = (salaryObject: SalaryStructure): number => {
let total = 0;
for (const name in salaryObject) {
total += salaryObject[name];
}
return total;
};
console.log(getTotalSalary(developerSalary)); // prints: 10160 rupees
Above we have an interface SalaryStructure
that is declared with an index signature. This demonstrates that the SalaryStructure
interface will have keys
of type string
and values of type number
.
Next, we declare a variable developerSalary
which is of type SalaryStructure
. Furthermore, we are creating a function getTotalSalary
that accepts a salary
object of type SalaryStructure
.
As we know that salaryObject
will be an object with a string
key and a number
value, we can use the for loop to loop through the salaryObject
to calculate the total salary.
DISADVANTAGE:
This syntax has the drawback of not having auto-suggestions supported by the IDE. Because IDE doesn't know the exact key that an object holds, it only knows the type of key it holds.
In addition, it doesn't raise a compilation error when we try to access a key that doesn't exist in the object, as you can see in the following image.
TYPECASTING:
In typecasting, a variable is transformed from one type to another.
JavaScript does not have a concept of type casting since variables have dynamic types.
By typecasting, you can tell TypeScript that a particular value is of a specific type, which would otherwise be impossible for TypeScript to detect by itself.
ACCESSING A DOM ELEMENT:
TypeScript warns you when properties/methods are called on DOM elements.
Here's an example to help you understand:
To run the following code snippets, you need to have
typescript
installed globally. If you don't havetypescript
installed, runnpm install -g typescript
in your terminal or check out my blog on Introduction to TypeScript to get started.
In the root folder, create the index.html
and index.ts
files.
In index.html
, add the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Typescript</title>
</head>
<body>
<input type="text" id="my-inp" />
<script src="./index.js"></script>
</body>
</html>
In index.ts
, add the following code:
let myInput = document.getElementById("my-inp");
console.log(myInput.value) // throws error
In the above code, we are trying to get a DOM element by its id
. The problem is that TypeScript does not know what type of element we are attempting to access.
So it infers it to be an element with type HTMLElement | null
, as seen in the following image:
As a developer, we know that the myInput
can't be null
since index.html
has an input
tag with the id="my-inp"
.
To inform TypeScript that the element with the id="my-inp"
exists we can use !
at the end of the value that might be null
, as follows:
const myInput = document.getElementById("my-inp")!;
Now, if you hover over myInput
you will see the type as HTMLElement
.
However, all DOM elements that are HTMLElement
does not have a property called value
. Yet, as a developer, we are aware that we are trying to access an input
element that has a value
property.
Therefore, to tell TypeScript that this element is of type HTMLInputElement
, we shall use Type Casting.
For type casing, there are two different syntaxes to choose from:
TYPECAST USING <>
OPERATOR:
The syntax we use to typecast the above code snippet uses the <>
operator is as follows:
const myInput = <HTMLInputElement>document.getElementById("my-inp")! ;
console.log(myInput.value) // runs without error
It is not a recommended way of doing typecasting because it is similar to JSX.
TYPECAST USING as
KEYWORD:
The syntax we use to typecast the above code snippet uses the as
keyword is as follows:
const myInput = document.getElementById("my-inp")! as HTMLInputElement;
console.log(myInput.value); // runs without error
FUNCTION OVERLOADING:
TypeScript supports the concept of function overloading
. Multiple functions can share the same name, but have different parameter types and return types. However, the number of parameters must be the same.
Let's take an example to understand this concept.
type Param = string | number;
function add(a: Param, b: Param) {
if (typeof a === "string" || typeof b === "string") {
// even if any of the value in `number` we are converting to string
return a.toString() + b.toString();
}
return a + b;
}
let result = add("John ", "Doe");
In the code above, we have a custom type called Param
that is a union type. Furthermore, we have an add
function that accepts two parameters a
and b
, both of type Param
.
Let's see what inferred type the result
variable has if we run the add
function with both parameters of the type string
.
As you can see in the above image, it appears to be a string | number
variable, which is correct to a certain extent, but it would cause a problem if we attempted to access any string method on the result
variable.
Since we are passing two strings
, the return type must also be a string
.
To fix this, we are going to implement function overloading as follows:
type Param = string | number;
function add(a: string, b: string): string;
function add(a: string, b: number): string;
function add(a: number, b: string): string;
function add(a: number, b: number): number;
function add(a: Param, b: Param) {
if (typeof a === "string" || typeof b === "string") {
// even if any of the value in `number` we are converting to string
return a.toString() + b.toString();
}
return a + b;
}
let result = add("John ", "Doe");
result.split(" "); // ["John", "Doe"] runs without an error
Here we are overloading a function add
by specifying all possible combinations of parameter types and expected return types. Therefore, now if we attempt to determine the inferred type of the result
variable, we will get a string
.
OPTIONAL CHAINING:
Consider a situation where you are making an API call and you don't know what data you will receive in the response.
In that case, you must use additional logic before accessing the nested object within the response data.
Let's take an example to understand this:
let getCourse = async () => {
// following api call will return {"id":1,"name":"typescript","description":"this is the description"}
await fetch(
"https://jsonware.com/api/v1/json/7ece5b94-268b-4b86-a3cd-aa0eb2f9b62b",
{
method: "GET",
}
)
.then((res) => res.json())
.then((data) => {
console.log(data); // data is the response from the API which is uncertain
});
};
getCourse();
Our async function getCourse
calls an API, and once the request is successful, we return the resultant object as data
.
For instance, let's say a data
object has an id
, name
, and description
keys, but we accidentally accessed the price
key, which doesn't exist.
Since the data
has been inferred to be of any
type, TypeScript will not throw an error instead it will ignore it.
Let's check out what happens when we access the price
key.
Here is what we see in the console.
It will throw a runtime error that states Cannot read properties of undefined (reading 'toFixed')
.
In this case, we can use Optional Chaining by adding ?
after the key name that might be null
or undefined
as follows:
// ...
.then((data) => {
console.log(data.price?.toFixed(2)); // this will fail silently
});
// ...
Here is what we see in the console.
It prints undefined
without throwing a runtime error.
NULLISH COALESCING:
Nullish coalescing is a loosely related concept to optional chaining, which is used to deal with undefined
or null
values.
Let's take an example to help you understand:
let inputValue = null;
let ignoreNullAndUndefined = inputValue || 'default';
console.log(ignoreNullAndUndefined); // prints 'default'
We have a variable called inputValue
with the value null
, as well as a variable called ignoreNullAndUndefined
that accepts values other than null
and undefined
.
Therefore, we used the ||
(OR) operator to ensure ignoreNullAndUndefined
does not receive null
or undefined
values.
The problem occurs when we declare inputValue
as ""
(empty string). Because the ||
(OR) operator treats it as a falsey
value, which we don't want.
Only null
and undefined
should be discarded.
Therefore, we can use the nullish coalescing operator ??
to only avoid null
and undefined
as follows:
let inputValue1 = 0;
let inputValue2 = "";
let inputValue3 = false;
let inputValue4 = null;
let ignoreNullAndUndefined1 = inputValue1 ?? "default";
let ignoreNullAndUndefined2 = inputValue2 ?? "default";
let ignoreNullAndUndefined3 = inputValue3 ?? "default";
let ignoreNullAndUndefined4 = inputValue4 ?? "default";
console.log(ignoreNullAndUndefined1); // prints 0
console.log(ignoreNullAndUndefined2); // prints ""
console.log(ignoreNullAndUndefined3); // prints false
console.log(ignoreNullAndUndefined4); // prints "default"
??
will only and only discard null
and undefined
and accept any other value, regardless of its truthiness or falsity.
CONCLUSION:
Despite TypeScript being very simple to grasp when performing basic tasks, knowing how its type system works is crucial to unlocking its advanced capabilities.
Once we understand how TypeScript works, we can use this knowledge to write cleaner, well-organized code.
Hopefully, this article has helped to demystify parts of the TypeScript type system and give you some ideas about how you can exploit its advanced features to improve your TypeScript application structure.
Make sure to subscribe to our newsletter on https://blog.wajeshubham.in/ and never miss any upcoming articles related to TypeScript and programming just like this one.
I hope this post will help you in your journey. Keep learning!