Mastering TypeScript: Beyond the Basics with Interfaces, Generics, and More

Many of us have already dabbled with the basic types TS provides and experienced the safety it provides. But Typescript, like an iceberg, has layers beneath its surface - features that can make our code more flexible, robust and maintainable. In this blog post, we'll have a look at Interfaces and Types, the elegance of Generics, to some delightful side features like optional chaining, nullish coalescing, etc.

Introduction

Most developers are probably familiar with the basic types that TS offers, like number, string, boolean, etc.

let username: string = "John Doe";  // Basic type
let age: number = 20;  // Basic type

However, the depth and versatility of TS lie within its advanced features. Here, we'll dive beyond these basics, into the more sophisticated corners of TypeScript's type system.

Interfaces & Types

Interfaces describe the structure of an object. They provide a way to define the contract or shape of an object. Used for ensuring the consistency of an object's structure throughout your application.

//Example 1
interface Person{
    name: string;
    age: number;
    greet() : void;
}

let user1 : Person = {
    name: "Gaurav",
    age: 30,

     greet(phrase: string) {
       console.log(phrase + " " + this.name);
     }
}

//Example 2
//interfaces as function types
interface AddFn {
  (a: number, b: number): number;
}

const add: AddFn = (a: number, b: number) => {
  return a + b;
};

Interfaces can also be used with classes. A class can implement an interface. It can be used for sharing features a class should have.

interface Greetable {
 //..
}

class Person implements Greetable {
 // ...
}

Type aliases create a new name for a type. Type aliases are sometimes similar to interfaces, but can name primitives, unions, tuples, and any other types that you’d otherwise have to write by hand.

type Second = number; //our custom type

let timeInSecond: number = 10;
let time: Second = 5;

//or
type WindowStates = "open" | "closed" | "minimized";

//or 
type myType = {
  name: string;
  age: number;
};

Type aliases act like interfaces only, but there are a few subtle differences. e.g. a type cannot be re-opened to add new properties but an interface is always extendable. That means they support multi-declarations, meaning you can declare the same interface name multiple times and they will be merged together.

//this is ok
interface MyWindow {
  title: string;
}
interface MyWindow { //interface is reopened here, properties will get merged
  isMaximized: boolean;
}

const win1: MyWindow = {
  title: "My window",
  isMaximized: true,
};


//Type Alias
type Window = {
  title: string
}
type Window = {
  isMinimized: string
}
// Error: Duplicate identifier 'Window'

Only interfaces support declaration merging, a feature where the compiler merges two interfaces with the same name into one single definition.

If you're planning to use third-party libraries or you're writing a library yourself, it's recommended to use interfaces. They can be easily extended (which means we can add new fields to an existing interface), offering better evolvability. On the other hand, if you can’t express some shape with an interface and you need to use a union or tuple type, type aliases are usually the way to go.

Generics

Generics allow developers to write flexible, reusable components without sacrificing type safety. Imagine writing functions or classes that work with various types without losing the benefit of type checks. So Generics is a component that can work with a variety of types rather than a single one.

e.g. we already have Array which itself is Generic in nature.

const names: Array<string> = [];
const nums: Array<number> = [];

A Generic function: this will give you a basic idea of how you can use generics and why are they useful.

function printData<T>(data: T){
    //logic
}

//Now you can pass anything to this function
printData(2);
printData("a string..");
printData([...]);
printData({..});


//We can have multiple arguments as well:
function merge<T, U>(objA: T, objB: U) {
  return Object.assign(objA, objB); //logic
}

Instead of working with all the types, we can also constrain the function to work with, for example, object type only. For this we can use extends keyword.

function merge<T extends object, U extends object>(objA: T, objB: U) {
  //logic
  return { ...objA, ...objB };
}
//Here, both the arguments only work with object type
console.log(merge({ name: "Gaurav" }, [1, 2]));
// {0: 1, 1: 2, name: 'Gaurav'}


const mergedObject = merge(
  { name: "Eren", hobbies: ["Cricket", "Anime"] },
  { age: 20 }
);
console.log(mergedObject); 
// {name: 'Gaurav', hobbies: Array(2), age: 20}

Interfaces and Generics: Generic interfaces can define types for specific properties or methods.

interface Container<T> {
    value: T;
    title: string;
}
const numberContainer: Container<number> = {value: 42, 
                                            title: "Meaning of Life"};

Here also we are basically constraining Generics to be of certain type. Another example could be:

interface Lengthy {
  length: number;
}

function countAndDescribe<T extends Lengthy>(element: T) {
  let description = "Got no value";
  if (element.length === 1) {
    description = "Got 1 element";
  } else if (element.length > 1) {
    description = "Got " + element.length + " elements";
  }

  return [element, description];
}

console.log(countAndDescribe(["Sports", "Cooking"]));
// [Array(2), 'Got 2 elements']

Generic Classes:

Generics shine brightly when used with classes, enabling them to maintain their type safety while being flexible. Let's build a Stack class, I mean a Generic Stack class.

class Stack<T> {
    private items: T[] = [];

    push(item: T) {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }
}

const numberStack = new Stack<number>();
numberStack.push(10);
console.log(numberStack.pop());  // 10

Side concepts

Type Casting

Sometimes, TS does not know the type of variables, so we developers can enforce types for variables. Type Casting allows us to explicitly mention/change the type of a value from one type to another.

// Using the 'as' syntax:
const input = document.getElementById("message-input")! as HTMLInputElement;

// Using the <> syntax (mostly used in JSX):
const input2 = <HTMLInputElement>document.getElementById("message-input")!;

This is useful as it would give you autocompletion and all the properties associated with that variable.

Optional Chaining

Optional chaining allows for more readable and concise expressions when accessing chained properties when there's a possibility that a reference might be undefined or null.

interface Address {
  city: string;
  pincode: number;
}

interface User {
  id: number;
  address?: Address;
}

const user1: User = {
  id: 1,
};
console.log(user1?.address?.city); //undefined

What happens is, if user1 exists, then only it'll go ahead. Then if address exists, then only it goes ahead, otherwise it short circuits. Without optional chaining, checking for city would require nested conditional checks.

Nullish Coalescing

The nullish coalescing operator (??) is a logical operator that returns the right-hand side operand when the left-hand side operand is null or undefined, and otherwise returns the left-hand side operand.

const input = undefined;
const name = input ?? "Default Name";
console.log(name);  // Outputs: "Default Name"

const input2 = '';
const res = input ?? 'DEFAULT';
console.log(res); // ''

This works only for null and undefined. If we have an empty string ' ', then it will be returned.

It's essential to note the difference between the || operator which returns the right-hand side operand when the left is any falsy value, not just null or undefined.

Conclusion

Harnessing TypeScript's full power requires diving deeper than the surface-level types and leveraging its more advanced features. The more we use its advanced tools, the better our code becomes. This isn't just about TypeScript; it's about writing better, more reliable software.

That is it for this blog! Let's meet again soon🤞🏼.

Thank you for staying till the end!