Jumpstart on advance typescript Part 1

Jumpstart on advance typescript Part 1

I started learning Typescript a couple of months back and initially, it was quite overwhelming for me because I used to write plain old javascript, where no strict type checking, no interfaces, no generic types. Seeing these feature/keywords in a large project was not recognized by my mental modals for initial few days at that moment just only one way I have left to do is to learn those jargons and make them my friend

In this blog, I will try to explain some of Typescript's advanced features which is I learned during this process and are very useful. The main idea is to explain it in the simplest way possible. I am also assuming you already have a basic understanding of how a strict type system works.

⚙️ Generics

A good piece of code is that which is reusable and flexible. Generics in Typescript provides a way to write components which can work over various kind of data type rather than a single one.

If we are performing some operation on more than one data types we don't need to repeat the same code block for each data type. Generics allow us to do so while keeping it type-safe at the same time.

Let's see an example where we are just returning what we have passed.

// Will return string
function rebound(arg: string): string {
    return arg
}

// Will return number
function rebound(arg: number): number {
    return arg
}

We can combine these two functions using any type

function rebound(arg: any): any {
    return arg
}

but as you already know any is not type safe, it means compiler can't catch error on compile time.

Now let's make it type safe using Generics

function rebound<T>(args: T): T {
    return args
}

You must be thinking

Wait the heck is <T> or T

These are called Type parameters. This lets users use this function with any type. It tells typescript which data-type argument is passed. let see the example of how to use it.

const message = rebound<string>('Hello, World'); // OK

const count = rebound<number>(200); // OK

const count = rebound<number>('200'); // Error, data-type expected is number but passed string

You can use multiple type parameters as well

function someFunction<T, U>(arg1: T, args2: U) { ... }

Not only functions, but Classes and interfaces can be Generic as well. let's see the below example where a generic interface is used as type

Generic interface as Type

interface KeyValuePair<T, U> {
    key: T;
    value: U;
}

let keyPair1: KeyValuePair<number, string> = { key:1, value:"Manish" }; // OK
let keyPair2: KeyValuePair<number, number> = { key:1, value:1988 }; // OK

As you can see in the above example, by using generic interface as type, we can specify the data type of key and value

Generic interface as Function Type

In same way, we can use interface as a function type.

interface KeyValueLogger<T, U>
{
    (key: T, val: U): void;
};

function logNumKeyPairs(key:number, value:number):void { 
    console.log('logNumKeyPairs: key = ' + key + ', value = ' + value)
}

function logStringKeyPairs(key: number, value:string):void { 
    console.log('logStringKeyPairs: key = '+ key + ', value = ' + value)
}

let numLogger: KeyValueLogger<number, number> = logNumKeyPairs;
numLogger(1, 12345); //Output: logNumKeyPairs: key = 1, value = 12345 

let strLogger: KeyValueLogger<number, string> = logStringKeyPairs;
strLogger(1, "kumar"); //Output: logStringKeyPairs: key = 1, value = Bill

Generic classes

So we learned about generic functions and interfaces. Similar way we can create generic classes too. Let's see below example

interface KeyValuePair<T, U> {
    key: T;
    value: U;
}

class Dictionary<T, U> {

    private keyValueCollection: Array<keyValuePair<T, U>>

    add(key:T, value: U) {
        if(this.keyValueCollection.findIndex(pair =>  pair.key === key) > -1) {
            throw new Error(key + ' key already exist in dictionary');
        }
        this.keyValueCollection.push({key, value});
    }

    remove(key: T) {
        const index = this.keyValueCollection.findIndex(pair =>  pair.key === key);

        if(index > -1) {
            this.keyValueCollection.splice(index, 1);
        } else {
            throw new Error(key + ' key does not exist in dictionary');
        }
    }
}

const dictionary = new Dictionary<string, string>();
dictionary.add('name', 'john');
dictionary.add('country', 'India');

You can see in the above example, I have used both generic interface and generic class, which gives us flexibility to create a HashMap with custom implementation.

⛓Conditional Type

Conditional type is something which we are not going to use very frequently directly but we use them indirectly a lot of time. In very simple terms it is a kind of ternary expression for Types. Let's look at a very basic example

type BookPages = bookId extends string[] ? number[] : number;

in above example, we are first check if bookId variable is a array of string, if yes then we will make BookPages array of number otherwise the only number

according to Typescript definition of conditional types

"A conditional type selects one of two possible types based on a condition expressed as a type relationship test"

Filtering using conditional type

As we already seen a basic example lets see some more examples to get a better understanding. Let's create a filter type which will select a subset of a union type

type Filter<T, U> = T extends U ? never : T;

type CarTypes = 'SUV' | 'HATCHBACK' | 'SEDAN' | 'MUSCLE' | 'VINTAGE';

type NonVintageCarTypes = Filter<CarTypes, 'VINTAGE'>; // 'SUV' | 'HATCHBACK' | 'SEDAN' | 'MUSCLE'

The conditional type here is just going to check each string literal in string literal union parameter (T in this case), is assignable to a string literal union under the second type parameter (U in this case). If yes, never is returned which means no result otherwise string literal is added to resulting union

In the above example, we filtered out one of union type from CarType and created a new type NonVintageCarTypes.

Basically this is how is in-built Exclude type is defined. So at this point, we can create Include type very easily, we just need to reverse the condition

type Include<T, U> = T extends U ? T : never;

Now we get some understanding about conditional type, hence it won't be very tough to create a copy of built-in NonNullable type using conditional type

type MyNonNullable<T> = T extends null ? never : T;

type SomeNullableType = string | number | null;

type SomeNonNullableType = MyNonNullable<SomeNullableType>;

Replace overloading with conditional types

Suppose there is an entertainment center which maintains a database of Books and TV series as below

interface Book {
  id: string;
  name: string;
  tableOfContents: string[];
}

interface TvSeries {
  id: number;
  name: string;
  Episode: number;
}

interface IItemService {
  getItem<T>(id: T): Book | Tv;
}

let itemService: IItemService;

In IItemService interface, getItem function can return detail of Book or TvSeries. If you look closely we have id as string then we need to return Book and if it is number we need to return TvSeries. We could achieve this with overloading as below and it works perfectly

interface IItemService {
  getItem(id: string): Book;
  getItem(id: number): Tv;
  getItem<T>(id: T): Book | Tv;
}

But using conditional types we can keep only one definition. Let's see below change

interface IItemService {
  getItem<T>(id: T): T extends string ? Book : Tv;
}

Isn't it very handy!

There are many ways we can use conditional type in conjunction with other Typescript fundamentals. I will try to cover some of those in my next blog. Thanks for reading it