Advance TypeScript: Take Your Development to the Next Level

Advance TypeScript: Take Your Development to the Next Level

Get familiar with some of Typescript's greatest advanced features.

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.

cover.png

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.

image.png

An interface CANNOT be created by intersecting two or more types or interfaces.

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, a union type refers to a value that can be one of the multiple types.

  • The & symbol represents an intersection, while the | symbol represents a union.

  • Those who have a background in programming may notice that these symbols are usually associated with logical expressions. The intersection (&) can be considered as an AND whereas the union (|) can be considered as an OR.

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:

image.png

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, and instanceof.

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:

image.png

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:

image.png

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.

image.png

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.

image.png

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 have typescript installed, run npm 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:

image.png

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.

image.png

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.

image.png

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.

image.png

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.

image.png

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.

image.png

Here is what we see in the console.

image.png

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.

image.png

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.

image.png

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!

My Website, connect with me on LinkedIn and GitHub