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.
Written by
Bhavik Bamania