Advanced Typescript

Advanced Typescript

A Complete Guide for Everyone

·

18 min read

Prologue

A wise developer (named Utkarsh) once said that if you want to become a successful Javascript developer, learn Typescript!

Following this mantra, we're today going to leave our JS realm to get ourselves into the new realm of Typescript.

Introduction

If you're a JS dev, you would have probably already heard Typescript a lot. For those who have not, fear not I'm here.

Typescript is basically a superset of JavaScript. It adds static type definitions in your code allowing you to define the types of variables, functions, and objects in your code. This helps to catch errors early on and make your code more maintainable. TypeScript also adds some new features, such as classes, interfaces, and generics.

Note: A valid JavaScript code is also a valid TypeScript code. However, vice versa is not true!

Remember that your browser does not understand Typescript. Thus, it is a common practice to compile TS code into JS before deploying it.

Why do we even need Typescript ?

JavaScript is a loosely typed language. So, it can be difficult to understand what types of data are being passed around in JavaScript.

In JS, function parameters and variables don't have any specific types. So, you may even not realize if you're passing wrong data in a function. This may result in alot of runtime errors.

TypeScript allows specifying the types of data being passed around within the code, and has the ability to report errors when the types don't match.

Configuring the TS project

To get started with Typescript, you need to have NodeJS installed in your system. Once NodeJS, is installed and all configured, use the below given command to install Typescript compiler via NPM.

npm install typescript --save-dev

To compile your TS code into JS, you can use the following command-

npx tsc --build

You can even add a tsconfig.json file in your project directory to override default behavior of Typescript. Follow this link to know more about tsconfig.json file.

The Basics

Type Assignment

In Typescript, you can assign types to a variable in two ways:-

  1. Implicit - TS guesses and assigns a type to a variable based on a given value.

  2. Explicit - You explicitly provide the type to your variable.

let name1: string = "Utkarsh"; // Explicit Type Assignment
let name2 = "Utkarsh" // Implicit Type Assignment

// if we try to re-assign name1 or name2 to a different datatype, 
// it would result in an error.
// For example:- 
name2 = true; // error

Note: Having TypeScript "guess" the type of a value is called infer. Implicit assignment forces TypeScript to infer the value.

Sometimes, TS may not be able to infer the type in case of Implicit Type Assignment. In such cases, TS will assign the type "any" to the variable. However, this behavior can be disabled using tsconfig.json.

Basic Types

TS has built-in support for the basic types such as string, number, and boolean. Since we've already seen how we can use these types in the above example, we'll not be discussing them here.

Literal Types

TS allows you to specify a fixed value for a variable or property. This fixed value can be a string, a number, a boolean or even null or undefined.

// String Literal type - 
let color: "red" | "green" | "blue";
color = "red";    // Valid
color = "yellow"; // Error

// Numerical Literal type - 
let diceFace: 1 | 2 | 3 | 4 | 5 | 6;
diceFace = 3;     // Valid
diceFace = 7;     // Error

// Boolean Literal type - 
let isTrue: true;
isTrue = true;    // Valid
isTrue = false;   // Error

// Undefined Literal type - 
let nullValue: null;
nullValue = null;  // Valid
nullValue = 42;    // Error

Special Types: any, unknown, never and void

  1. any - Any is a special type that completely disables type checking and effectively allows all types to be used for a given variable.

     let var1: any = "Test";
     var1 = 1000; // this does not give error as we used "any" type
    

    Remember, while "any" can useful to get past TS errors. However, this type also disables type safety and auto completion.

    It is usually advised not to use "any" in your code.

  2. unknown - This type can be thought of as a safer alternative to "any". It can be used whenever we're not sure about the type or the structure of data being received. For example, "unknown" can be used when we fetch some data from an API endpoint.

     let var1: unknown = "test";
     var1 = true; // no error again
    

    The main difference between any and unknown is that : any allows you to use all JS methods (such as valueOf(), toLowerCase(), etc) on the variable.

    However, unknown does not allow you to use these methods without type assertion. You can learn more about unknown here.

  3. never - This type effectively throws an error whenever it is defined.

     let x: never = true; // Error: Type 'boolean' is not assignable
     // to type 'never'.
    
     // never is rarely used in a codebase. It is primarily used
     // in advanced generics.
    
  4. void - Void is a special type that is used when there is no data. For example, when a given function does not return anything, we can use void as its return type.

     function myFunc(): void {
         console.log("Hello World !");
     }
    

Tuples, Enums and Object Types

Tuples

Tuples are the typed arrays in TS. They have a fixed length and fixed types for each index.

In practical use cases, Tuples are generally made readonly because Tuples only have strongly defined types for the initial values.

Tuples can also be created as named tuples by adding an identifier for each of the values. It essentially helps during destructuring.

let myTuple: [number, string, boolean];

myTuple = [1, "hello", true]; // valid tuple

myTuple.push("new value");
// no type safety for index >= 3.
// Better to make them readonly

// Readonly Tuples - 
const newTuple: readonly [number, string, boolean] = [1, "hello", true];
newTuple.push("new value"); // throws error

// Named Tuples
const coordinates: [x: number, y:number] = [80, 79];

Enums

In TS, an enum is a special "class" that represents a group of constants. They offer better type safety than JS constant objects.

// Non Initialized Enums (returns the index) - 
enum StatusCodes {
  NotFound,
  Success,
  Accepted,
  BadRequest
};
console.log(StatusCodes.Accepted); // prints 2

// Initialized Enums
enum Directions {
  UP = 'UP',
  DOWN = "DOWN",
  LEFT = "LEFT",
  RIGHT = "RIGHT"
};
console.log(Directions.UP); // prints 'UP'

Object

The Objects in Typescript can be initialized with a given set of properties -

type Person = {
    name: string,
    age: number
}
const p1: Person  = {
    name: "test",
    age: 31,
}

Note: To destructure tuples or objects, we can simply use Array/ Object destructuring. Since, Array/ Object destructuring in TS is exactly same as in JS, it is not covered here.

Interfaces and Type Alias

In TS, we can define our types separately from the variables that use them. This allows these Aliases and Interfaces allows types to be easily shared between different variables and objects.

  1. Type Alias - Type Aliases allow us to define types with a custom name (i.e., an Alias). They can be used for primitives like string, number or more complex types such as objects and arrays.

  2. Interfaces - Interfaces are similar to type aliases, except they only apply to object types. Further, Interfaces can be extended just like classes.

// type alias - 
type PersonAge = number; // simple type

type Person = {
    name: string,
    age: number
}; // complex type

const age: PersonAge = 12;
const p1: Person = {name: "john wick", age: 31};

// interfaces
interface Car {
    model: string;
    year: number;
    price: number;
}
const nexaCar: Car = {model: "fronx", year: 2023, price: 800000};

// extending an interface
interface BasePerson {
    name: string;
    age: number;
}

interface Employee extends Person {
    salary: number;
    department?: string; // using '?' marks the property as optional.
}
const emp1: Employee = {
    name: "Mark Spectre",
    age: 28,
    salary: 0,
};

Note: '?' can be used for marking a property as optional in both Type Alias and Interfaces. parameter?: type is a shorthand for parameter: type | undefined .

Did you know - Type Alias cannot have cyclic objects whereas interfaces can!

Unions and intersections

Unions and intersections can be thought of as the Logical AND and OR operators for Alias and Interfaces in TS.

Union

Unions can be seen as Logical OR in TS. For example, when we expect a parameter or a variable to be of one of the many available types, we can use a union.

For example, consider - let var: string | number = 10;

However, it is important to note that unions behave a bit different as one may expect in case of interfaces. For clarity, consider the below example -

interface Dog {
    bark(): void;
    eat(): void;
}

interface Fish {
    swim(): void;
    eat(): void;
}

const animal: Dog | Fish = new Animal();

animal.bark();
// gives error - Property 'bark' does not exist on type 'Dog | Fish'.

animal.eat(); // works perfectly

// in case of unions for interfaces, we can use only the common
// properties.

Now that you know about unions, there is a practical way of using unions known as Discriminating Unions. They allow you to model a fixed set of related types. All the different types in the union must contain a common property that helps differentiating between them. This property is called a discriminant.

// Define a common property (discriminant)
type ShapeType = "circle" | "square" | "triangle";

// Define individual shape interfaces with discriminant property
interface Circle {
  type: "circle"; // discriminant
  radius: number;
}

interface Square {
  type: "square"; // discriminant
  sideLength: number;
}

interface Triangle {
  type: "triangle"; // discriminant
  sideA: number;
  sideB: number;
  sideC: number;
}

// Create a discriminating union type using the shape interfaces
type Shape = Circle | Square | Triangle;

Intersections

Intersections are closely related to unions. They combines multiple types into a single type. This allows you to add together existing types to get a single type that has all the features you need.

type Dog = {
  name: string;
  breed: string;
  bark: () => void;
};

type Robot = {
  serialNumber: string;
  move: () => void;
};

// Define a new type by intersecting Dog and Robot types
type DogRobot = Dog & Robot;

// Create an object that conforms to DogRobot type
const myDogRobot: DogRobot = {
  name: "Buddy",
  breed: "Golden Retriever",
  bark: () => {
    console.log("Woof!");
  },
  serialNumber: "123456",
  move: () => {
    console.log("Walking forward");
  },
};

// Now, you can use myDogRobot with all properties
myDogRobot.bark(); // Outputs: Woof!
myDogRobot.move(); // Outputs: Walking forward

// some interesting cases
// -- let us now try to declare a variable - 
const x: string & number = 10;
// what would be the output here? In case you haven't expected
// this results in 'x' having the type 'never' and gives us error
// as a variable with type 'never' can never be initialized.

Type Narrowing

Type narrowing is the process of refining the type of a variable block based on runtime checks. TS uses control flow analysis to narrow down the type of a variable within conditional statements, making the type system more expressive and allowing for more accurate type inference.

There are various ways to perform type narrowing such as -

  1. Type Guards: Type guards are functions or expressions that help TypeScript narrow down the type of a variable within a certain scope. The most common type guards include typeof, instanceof, and user-defined type guards.

     function someFunction(value: string | number): string {
       if (typeof value === "string") {
         // value is narrowed to string inside this block
         return value.toUpperCase();
       } else {
         // value is narrowed to number inside this block
         return value.toFixed(2);
       }
     }
    
  2. Truthy/Falsy Checks: You can use truthy and falsy checks to narrow down the type based on the value's truthiness.

     function someFunction(value: string | null): string {
       if (value) {
         // value is narrowed to string (truthy) inside this block
         return value.toUpperCase();
       } else {
         // value is narrowed to null (falsy) inside this block
         return "Default";
       }
     }
    
  3. Custom Type Predicates: You can create custom type predicates to explicitly specify type narrowing rules.

     interface Car {
       make: string;
       model: string;
     }
    
     function isCar(obj: any): obj is Car {
       return obj && typeof obj.make === 'string' && typeof obj.model === 'string';
     }
    
     function exampleCustomTypePredicate(obj: Car | string): string {
       if (isCar(obj)) {
         // obj is narrowed to Car inside this block
         return `${obj.make} ${obj.model}`;
       } else {
         // obj is narrowed to string inside this block
         return obj.toUpperCase();
       }
     }
    

Generics

Generics in TS allow us to create flexible and reusable functions, classes, and interfaces without sacrificing type safety. They operate by using placeholders called type parameters that can be replaced with specific types later when using the generic code.

Some Key Concepts

  • Type Parameters: Represented by letters like T, U, K, etc. They stand for placeholder types that will be replaced with concrete types when the generic code is used.

  • Generic Constraints: Rules that restrict the types that can be used for type parameters. This ensures type safety and prevents misuse.

  • Variance: Controls how type parameters are affected by subtyping relationships. Invariants are the most common choice, ensuring consistency across type relationships.

Below given are some examples of generics in Typescript -

 // example 1
interface Sports { 
    name: string; 
} 

function printSportName<T extends Sports>(sport: T): void { 
    console.log(sport.name); 
} 
let sport: Sports = { name: "baseball" }; 
printSportName(sport);

// example 2

Let's get started

Lets try to understand the below given example -

function returnValue<T>(value: T): T {
  return value;
}

const num = returnValue(42); // num is number
const str = returnValue("hello"); // str is string

In this example, it is evident that the Type Parameter T takes the value of either string or number based on the type of argument passed.

Since now this example is clear, let's try to decode a more complex case-

class Pair<T, U> {
  private first: T;
  private second: U;

  constructor(first: T, second: U) {
    this.first = first;
    this.second = second;
  }

  getFirst(): T {
    return this.first;
  }

  getSecond(): U {
    return this.second;
  }
}

const pair1 = new Pair<number, string>(1, "apple");
const pair2 = new Pair<boolean, object>(true, { name: "John" });

The Utility Types

TS, by default, provides us some predefined type functions that provide convenient transformations and operations on existing types. These predefined type functions are known as Utility Types.

They allow you to create new types based on existing types by modifying or extracting properties, making properties optional or required, creating immutable versions of types, and more.

A complete guide to Utility Guides is given here.

Advanced Concepts

infer

infer keyword is used in the context of conditional types to infer and capture the type of a variable within the true branch of a conditional type. It allows you to create more flexible and generic type conditions.

Consider the following example -

type ExtractString<T> = T extends string ? T : never;

type MyStringType = ExtractString<"Hello">; // MyStringType is "Hello"
type NotAStringType = ExtractString<number>; // NotAStringType is never

Now, what if instead of string, you had an object? How would you apply conditional constraints in that case? To solve this issue, we use infer.

type ExtractProps<T> = T extends { props: infer P } ? P : never;

type ComponentProps = { props: { id: number; name: string } };
type ExtractedProps = ExtractProps<ComponentProps>; // ExtractedProps is { id: number; name: string }

The infer keyword is particularly useful when you want to extract or manipulate nested types within a conditional type. It allows you to capture and reuse these inferred types in a more flexible way.

keyof and typeof

  • keyof - This TS operator that is used to obtain the union type of all possible property names of a given type.

  • typeof - This TS operator is used to obtain the type of a variable, expression, or value at compile time. It is a way to refer to the type of a variable without actually creating an instance of that type.

// keyof
type Person = {
  name: string;
  age: number;
  address: string;
};
type KeysOfPerson = keyof Person; // "name" | "age" | "address"

// typeof
const str = "Hello, TypeScript!";
let myVariable: typeof str; // myVariable has type string
myVariable = "New value"; // Valid
myVariable = 42; // Error

const myObject = {
  name: "John",
  age: 30,
  address: "123 Main St",
};
function getProperty(obj: typeof myObject, key: keyof typeof myObject) {
  return obj[key];
}
const nameValue = getProperty(myObject, "name"); // "John"

Satisfies Operator

he satisfies operator provides a way to check if a given type meets the requirements of a specific interface or condition while preserving the exact type during inference. This is particularly useful when dealing with values that can have different concrete types but share a common structure.

It is usually used with -

  • Discriminated unions

      interface Shape {
        kind: 'circle' | 'rectangle';
      }
    
      interface Circle extends Shape {
        radius: number;
      }
    
      interface Rectangle extends Shape {
        width: number;
        height: number;
      }
    
      function getArea(shape: Shape) {
        if (shape.kind === 'circle') {
          // Access radius if satisfies Circle
          if (shape satisfies Circle) {
            return Math.PI * shape.radius * shape.radius;
          }
          // Handle incorrect type here
        } else if (shape.kind === 'rectangle') {
          // Access width and height if satisfies Rectangle
          if (shape satisfies Rectangle) {
            return shape.width * shape.height;
          }
        }
      }
    
  • Complex object checks

      interface UserBase {
        name: string;
        email: string;
      }
    
      interface UserWithAge extends UserBase {
        age: number;
      }
    
      interface UserWithLocation extends UserBase {
        location: string;
      }
    
      type UserProfile = UserBase | UserWithAge | UserWithLocation;
    
      function processUserProfile(profile: UserProfile) {
        if (profile satisfies UserBase) {
          // Access name and email safely
          console.log(profile.name, profile.email);
    
          // Optionally check for additional properties
          if (profile satisfies UserWithAge) {
            console.log("Age:", profile.age);
          }
          if (profile satisfies UserWithLocation) {
            console.log("Location:", profile.location);
          }
        }
      }
    
  • Function parameter validation

      function processData(data: { id: string; value: unknown }) {
        if (data satisfies { value: number }) {
          // Use value as a number
        } else if (data satisfies { value: string }) {
          // Use value as a string
        } else {
          // Handle other value types
        }
      }
    

as const

This operator is used for enforcing Immutability to objects or array literals. It also makes the objects/array literals read-only. This means you cannot modify its properties or the order of its elements after creation.

const person = {
    name: "Alice",
    age: 30 
} as const;
// person now has type { name: 'Alice', age:30 }

using

using plays a specific role in resource management, focusing on ensuring proper disposal of resources like file handles, database connections, etc. while improving code readability and safety.

It takes care of automatic disposal of resources when they are no longer needed, simplifying resource management and preventing potential memory leaks or other issues.

import * as fs from 'fs';

function readFile(path: string) {
  using (const file = fs.openSync(path, 'r')) {
    const buffer = fs.readFileSync(file);
    // Use the buffer
    console.log(buffer.toString());
  } // The file gets closed here automatically
}

readFile('my-file.txt');

Type Assertions

There are many cases where you have more information about the type of a value that TypeScript can’t know about.

For example, if you’re using document.getElementById, TypeScript only knows that this will return some kind of HTMLElement, but you might know that your page will always have an HTMLCanvasElement with a given ID.

In such cases, you can use Type Assertions. For example -

const myCanvas1 = document.getElementById("main_canvas") as HTMLCanvasElement;
const myCanvas2 = <HTMLCanvasElement>document.getElementById("main_canvas");

Index Types, Mapped Types and Conditional Types

  • Index Types - When we represent the types of properties within an object based on their keys, they're called index types. We often use square brackets and a key type within them to define the relationship.

      interface User {
          [key: string]: string | number; // Properties can have string or number values
      }
      const user: User = {
          name: "Alice",
          age: 30
      };
      // user.name is inferred as string, user.age as number
    
  • Mapped Types - When we create new types by iterating over properties of existing types, they're called mapped types. They usually follow this syntax - type NewType = { [Key in OldTypeKey]: NewValueType }

      type Readonly<T> = { readonly[Key in keyof T]: T[Key] }; // Make all properties readonly
    
      type User = {
      name: string,
      age: number,
      }
    
      type ReadonlyUser = Readonly<User>;
      const readOnlyUser: ReadonlyUser = {
          name: "Alice",
          age: 30 // Error: cannot edit readonly property
      };
    

Conditional Types - When we use some conditional logic within mapped types to create different types based on certain conditions, it is called conditional mapping.

interface Animal {
}
interface Dog extends Animal {
}

type Ex1 = Dog extends Animal ? number : string;
//type of Ex1 = number

type Ex2 = RegExp extends Animal ? number : string;
// type of Ex2 = string

Note: Often times, TS needs to gracefully fail when it detects possibly infinite recursion during conditional mappings. In such cases, tail-recursion elimination on conditional types so that TS does not go off the rails to compute deeply nested mappings.

The Awaited Type

TS V4.5 introduced a new utility type called the Awaited type. They're meant to model operations like await in async functions, or the .then() method on Promises .

// A = string
type A = Awaited<Promise<string>>;
// B = number
type B = Awaited<Promise<Promise<number>>>;
// C = boolean | number
type C = Awaited<boolean | Promise<number>>;

Conclusion

In conclusion, venturing into the realm of TypeScript proves to be a wise decision for any JavaScript developer aspiring for success.

As a superset of JavaScript, TypeScript not only introduces static type definitions but also enhances code maintainability and early error detection. With features like classes, interfaces, generics, and advanced concepts such as conditional types and index types, TypeScript offers a robust toolkit for developers to build scalable and reliable applications.

As we embrace TypeScript, we open doors to a world of enhanced development experiences, improved collaboration, and ultimately, more successful and resilient software projects.

So, heed the advice of Utkarsh (the wise developer 😏), and embark on your TypeScript journey for a brighter and more productive coding future.

Did you find this article valuable?

Support Utkarsh by becoming a sponsor. Any amount is appreciated!