Typescript and its advanced types

Profile picture of Bhavik Bamania
Bhavik Bamania
·8 min read
Typescript and its advanced types
Typescript and its advanced types

If you are following through our typescript then you have been feeling pretty bored working with lots and lots of basic types. Therefore, enough talking on basics it's time to switch your gears and jump on something really interesting ie., the advanced types that the typescript offers.

Before you proceed

If you are going with the sequence, then you must be aware of concepts like what is typescript, how we can configure typescript from scratch, what are types in typescript, and how you can work with them while working with Javascript classes and objects. 

However, if you are not aware of any of these and came across this article directly, don’t worry here you can take a deep dive into the above-mentioned topics.

Getting Started

Okay, we are almost ready to take off, but before that let me tell you one very important thing, this article will have a few demos and hands-on stuff, so it is advised to do a bare minimum setup of typescript simply following this article[url to first article]. Already have one? okay, then let's go!

Intersection Types

Intersection types allow you to combine multiple types into one. This is useful when you want to create a new type that includes all properties from several types. Here’s an example of how intersection types work:

type Basic = {
  name: string;
  role: string;
}

type MoreDetails = {
  birthdate: Date;
}

type UserDetails = Basic & MoreDetails;

const user: UserDetails = {
  name: "Oscar",
  role: "User",
  birthdate: new Date(2000, 11, 17)
}

In this example, we created two types, Basic and MoreDetails, and combined them using the & operator to form a new type, UserDetails. Now, any object of type UserDetails must have a name, role, and birthdate.

Why Use Types Instead of Interfaces?

While intersection types can be used with object types, you might wonder why you should use them instead of interfaces. Both approaches are valid, and the choice often comes down to personal preference or specific use cases.

For instance, you can achieve the same result with interfaces:

interface Basic {
  name: string;
  role: string;
}

interface MoreDetails {
  birthdate: Date;
}

type UserDetails = Basic & MoreDetails;

const user: UserDetails = {
  name: "Oscar",
  role: "User",
  birthdate: new Date(2000, 11, 17)
}

Or by extending interfaces:

interface Basic {
  name: string;
  role: string;
}

interface MoreDetails {
  birthdate: Date;
}

interface UserDetails extends Basic, MoreDetails {}

const user: UserDetails = {
  name: "Oscar",
  role: "User",
  birthdate: new Date(2000, 11, 17)
}

Both approaches are absolutely fine and you can use interfaces in such a scenario, however, one argument could be provided that the intersection types enable some sort of code optimization, but as I said it is completely upon your choice to use an intersection type or simply move ahead with interfaces.

It is worth noting that intersection types can be handy while using with object as we have used above but it can be used with any types provided by typescript. Such as we could create a new type like this.

type Anything = string | number;
type OnlyNumber = number;

type Universal = Anything & OnlyNumber;

Therefore, it can be used with any type be it with basic types, a union type, or with an object. It allows us to create an intersection type of two types in the case of an object it will create an intersection of two objects, whereas, in the case of a union, it will create an intersection of two unions or a union with a basic type.

Type Guards

Type guards allow you to determine the type of a variable at runtime, which is especially useful when working with union types. They enable you to write flexible code while ensuring type safety.

Using Type Guards with Built-in Types

Here’s an example of a type guard using built-in types:

type Combined = string | number;

function addFn(a: Combined, b: Combined) {
  if (typeof a === 'string' || typeof b === 'string') {
    return a.toString() + b.toString();
  }
  return a + b;
}

Using Type Guards with Custom Types

Type guards can also be used with custom types:

type User = {
  name: string;
  startDate: Date;
}

type Admin = {
  name: string;
  privilege: string;
  startDate: Date;
}

type UnknownType = User | Admin;

function printUserDetails(user: UnknownType) {
  if ('privilege' in user) {
    return user.privilege;
  }
  if ('startDate' in user) {
    return user.startDate;
  }
  return user.name;
}

Type Guards with Classes

Type guards can be used with classes in two ways:

Using the in Keyword

class Car {
  drive() {
    console.log("driving a car");
  }
}

class Truck {
  drive() {
    console.log("driving a truck");
  }
  loadCargo(amount: number) {
    console.log("loading cargo" + amount);
  }
}

type Vehicle = Car | Truck;

const vehicle1 = new Car();
const vehicle2 = new Truck();

function useVehicle(vehicle: Vehicle) {
  vehicle.drive();
  if ("loadCargo" in vehicle) {
    vehicle.loadCargo(1000);
  }
}

useVehicle(vehicle1);
useVehicle(vehicle2);

With instanceof

class Car {
  drive() {
    console.log("driving a car");
  }
}

class Truck {
  drive() {
    console.log("driving a truck");
  }
  loadCargo(amount: number) {
    console.log("loading cargo" + amount);
  }
}

type Vehicle = Car | Truck;

const vehicle1 = new Car();
const vehicle2 = new Truck();

function useVehicle(vehicle: Vehicle) {
  vehicle.drive();
  if (vehicle instanceof Truck) {
    vehicle.loadCargo(1000);
  }
}

useVehicle(vehicle1);
useVehicle(vehicle2);

In the above example, we are simply using Javascript the instanceof is a Javascript operator built in Vanila Javascript which will execute and validate at the runtime.

Here, Javascript doesn’t know about the Truck type but it knows the constructor functions and in the end classes in Javascript turns into a constructor functions.

However, we can’t used instanceof with interface, since interface doesn’t complied into any Javascript code.

Discriminated Union

A discriminated union is a pattern used with union types that makes implementing type guards easier, especially when working with objects. It involves using a common property to distinguish between different types in a union.

interface Sprinter {
  type: 'sprinter';
  runningSpeed: number;
}

interface Driver {
  type: 'driver';
  drivingSpeed: number;
}

type Athlete = Sprinter | Driver;

function athleteSpeed(athlete: Athlete) {
  let speed;
  switch (athlete.type) {
    case 'sprinter':
      speed = athlete.runningSpeed;
      break;
    case 'driver':
      speed = athlete.drivingSpeed;
      break;
  }
  console.log("Running at speed:" + speed);
}

athleteSpeed({ type: 'sprinter', runningSpeed: 20 });

In this example, the type property serves as a discriminant, allowing us to determine the specific type of Athlete.

Typecasting

Typecasting (or type assertion) in TypeScript allows you to explicitly tell the compiler to treat a variable as a specific type. This is useful when you know more about the type of a value than TypeScript can infer.


// Example HTML: <input id="user-input" type="text">

// Access the element using TypeScript
const inputElement = document.getElementById("user-input");

// Type assertion to treat inputElement as an HTMLInputElement
const typedInputElement = inputElement as HTMLInputElement;

// Now you can safely access properties specific to HTMLInputElement
typedInputElement.value = "Hello, World!";

console.log(typedInputElement.value); // Output: "Hello, World!"

Index Types

Index types allow you to create flexible objects with dynamic properties.

interface ErrorContainer {
  id: string;
  [prop: string]: string;
}

const errorBag: ErrorContainer = {
  email: "Not a valid email"):
  username: "Not start with a capital chracter.
}

In this example, ErrorContainer can have any number of string properties in addition to id, which must be a string.

Function Overloads

Function overloads let you define multiple function signatures for a single function, enabling you to call a function with different parameter types.

type Combined = string | number;

function addFn(a: number, b: number): number;
function addFn(a: string, b: string): string;
function addFn(a: Combined, b: Combined) {
  if (typeof a === 'string' || typeof b === 'string') {
    return a.toString() + b.toString();
  }
  return a + b;
}

const result1 = addFn("Pyramid", "Oscar");
console.log(result1.split(' ')); // works

const result2 = addFn(5, 10);
console.log(result2); // works

Optional Chaining

Optional chaining was introduced in 3.7, allows you to safely access deeply nested properties of an object without having to check if each reference in the chain is null or undefined. If any part of the chain is null or undefined, the entire expression evaluates to undefined.

interface User {
  name: string;
  address?: {
    street?: string;
    city?: string;
  };
}

const user1: User = {
  name: "Oscar",
  address: {
    street: "123 Main St",
    city: "Auckland",
  },
};

const user2: User = {
  name: "Pyramid",
  // No address property
};

// Using optional chaining to access the street property
const street1 = user1.address?.street; // "123 Main St"
const street2 = user2.address?.street; // undefined

console.log(street1); // Output: "123 Main St"
console.log(street2); // Output: undefined

Nullish Coalescing

Nullish coalescing is a feature that helps you handle nullish values (null or undefined) without mistakenly treating falsy values like 0 or "" as nullish.

const userInput = null;
const storedData = userInput ?? 'DEFAULT'; // storedData will be 'DEFAULT'

const userInput2 = "";
const storedData2 = userInput2 ?? 'DEFAULT'; // storedData2 will be ''

In this example, the ?? operator ensures that storedData is only set to 'DEFAULT' if userInput is null or undefined, not when it is an empty string or zero.

Conclusion

By understanding and leveraging TypeScript's advanced types, such as intersection types, type guards, and discriminated unions, you can write safer and more efficient code. These features enhance TypeScript's ability to handle complex data structures and improve type safety, enabling developers to create robust applications. Advanced types help in ensuring that your code is both flexible and reliable, allowing for easier maintenance and scalability in large projects. Embracing these advanced concepts can significantly elevate your TypeScript development skills and streamline your workflow.

Author of Bhavik Bamania

Written by

Bhavik Bamania