18 - Some advanced TS goodness

Ey yo, it’s your boy coming back at you again with some more programming wisdom. How have you been doing, Andy? We should play some CS sometime. WELL, a lot of things (as usual) have happened since my last blog post, but considering this is a technical blog, and you came here to read about technical stuff, well let’s get cracking down to business, and if you wat to be friends and listen about my personal drama, then you can always hit me up…erm somewhere?

Today’s post motivation

I found myself having some free time recently (thanks Ben), and during that free time, I was wondering what skill I should sharpen. I have been working with TypeScript and have been enjoying it, but to be completely frank with you, there have been some weird things in good old TS that been… well weirding me out. So I set off on a mission to understand what those things are, and document them for you (and me) to read here.

So without further ado, let’s get into it!

Declaration merging

In TypeScript, and in programming in general, we name things all the time. Variables, functions, classes, methods, interfaces, everything has a name, right?

Sometimes we can give the same name to different things, like for an example, we can give a type and a variable the same name, and we will be able to use those two things. TS will be ok with that, and this is where declaration merging comes into play.

Today we’ll aim to understand how declaration merging works and how to tell what type of named identifier we’re working with. Is it a type, is it a class, is it a value? Let’s see. For this example, we’re helping our grandma’s fruit stand, which we’ve decided to digitalize.

type Fruit = {};
const Fruit = 'banana';

Now, TS will “stack” the name “Fruit” on both of these things we’re naming. But how can we tell which one is is which?

const myFruit = Fruit; // if we can assign the name on the right hand side, we're working with a value
const someFruit: Fruit = {}; // if we can assign on the left hand side, we're working with a type

Pretty simple, right? Classes have a slightly different approach, where we can do both left hand and right hand assignments and we’ll get a different result.

class Fruit {
    name?: string;
    isBanana?: boolean;
    
    static create(name: string, isBanana: boolean) {

    }
}

Now, if we do

const someFruit = Fruit;

We assign the type directly as a value to a variable, (right hand assignment) and we can do

someFruit.create();

Meaning, we can access the “factory” methods that the Fruit class has

And if we do

const someOtherFruit: Fruit = {};

We’ve assigned the type of the variable (left hand assignment), and we treat it as an instance of the Fruit class, giving us access like so:

someOtherFruit.name;
someOtherFruit.isBanana;

And this folks is how declaration merging works in TS and how to tell what kind of a named identifier we’re working with.

Type queries

Type queries is what allows us to obtain information about a certain type. Let’s start with the one I had more troubles wrapping my head around at first, but later in the post we’ll find out how exactly it plays a special role when working with mapped types.

keyof - this will give us a literal numeric or string union representation of a type’s keys.

Let’s see an example:

type Fruit = {
  color: string;
  isBanana: boolean;
}

type FruitKeys = keyof Fruit;

Now, the type FruitKeys can either be 'color' or 'isBanana';

If we want to use it as a variable, we can do one of two things

const banana: FruitKeys = 'color';
const anotherBanana: FruitKeys = 'isBanana';
const thirdBanana: FruitKeys = 'anything different'; // this will error out

Let’s look at another example before moving on, using an intersection operator, we can do:

type K = keyof Date;
type D = K & string; // this will give us all of the keys of the date type
					// which are strings

I know the keyof does not look super useful right now, and you’re probably wondering when you’ll ever need it, but trust me, we’ll get there and it will make sense.

typeof - we use this, when we have a value, and we want to know the type that describes this value.

Let’s look at another example:

type Cat = {
    tails: number; // cause only cats have tails
}

const myCat: Cat = {tails: 1}

Ok, so if we have our own types and values that conform to them, we’d figure that we’d know them right? But what if we’re consuming a library, and we don’t know the type of a value, or maybe we want to take a type and modify it for our use cases? We can do something like:

type PossiblyEvilCat = typeof myCat & {evil: boolean};
const myEvilCat: PossiblyEvilCat = {tails: 666, evil: true};

Conditional types

Here’s another one that at first it’s going to seem like there’s not much use for it, but it will make a lot more sense later on.

Conditional types use the same syntax as a ternary expression in JS.

Remember how ternaries look like?

const isNegative = n < 0 ? true : false;

We can use the exact same syntax for types.

class Banana {
    peel () {}
}

class Coconut {
    crack () {}
}

We’re still helping our grandma and her fruit stand, and we want to be a bit more dynamic with our fruits. Let’s create a type that based on a condition will result to either a Banana or a Coconut:

By using a type paramenter in our type and the extends keyword in a ternary expression (remember that extends means “does this thing cover the minimum of the thing that is followed by the extends keyword?), we can do this:

type Fruit<T> = T extends 'banana' ? Banana : Coconut;

let fruit1: Fruit<'banana'>; // fruit1.peel()
let fruit2: Fruit<'anything else'>; // fruit2.crack();

I know that this 'banana' followed by the extends keyword seems silly, since we’re givin a literal string value here to check against, but as I said, hold your horses and you’ll see how conditional types become super powerful when combined with the infer keyword.

type Fruit<T> = T extends 'banana' ? Banana : Coconut;

Extract and Exclude

Ok, so let’s imagine we have this complex type that represents a config for CSS colours:

type CSSColours = 
    'red' |
    'green' |
    'blue' |
    'teal' |
    'peachpuff' |
    [number, number, number] // rgb(40, 30, 23)

And we decide to limit our user to only use the string types. We can do that with Extract like so:

type StringColours = Extract<CSSColours, string>;
// type StringColours = "red" | "green" | "blue" | "teal" | "peachpuff"

If we decide we want to use just the number tuple, we can use Exclude likes so:

type RGBColours = Exclude<CSSColours, string>;
// type RGBColours = [number, number, number]

Basically, when we use Extract on a type, we are saying:

“Give me everything on this type that matches the thing I am trying to extract.”

And if we use Exclude, it’s the other way around:

“Give me everything on this type, that is not the thing I am trying to exclude.”

Inference with conditional types

Okay, we’re now approaching a place, where we can combine whatever we have learned so far into something really useful, and really cool. Let’s imagine a scenario, where we’re using a library, and we’re importing a class from it, that has a very complex config parameter in its constructor. To make things more practical, and because I am currently in this super cool coworking space in France, let’s imagine we’re writing a program for a coworking space, and each time a person comes into our space, we’ll create a separate CoWorking session for them, with a config file that will reflect the way they use the coworking space.

class CoWorking {
    constructor(cfg: {
      personName?: string;
      startTime?: Date;
      endTime?: Date;
      includeCoffee?: boolean;
      isGuestSubscribed?: boolean;
      // ...
      // imagine a much more complicated object
    }) {
        
    }
}

For the sake of this post, we’ll keep this cfg in the code example simple, but in reality, we might be importing a class that is really complicated.

Now, a guest comes in, and the clerk at the coworking desk fills in a form and submits it. We get the inputs from that form and create a config constant, which we’ll pass to the constructor:

const andysConfig: CoWorkingArgs = {
  personName: 'Andrew Healey',
  startTime: new Date(),
  includeCoffee: true,
  isGuestSubscibe: false, // oh no, a typo
}

const andySession = new CoWorking(andysConfig);

Now, in this situation, we’re kinda in a pickle. We’re importing a class from a third party library, and sure, we can dig down in the node_modules and look at the source code and find out what the cfg object should look like, but that will take time and frustration. Is there any other easier and better way to find out what the type of the config is, so we can conform to it?

Well yes there is, enter Inference with conditional types! Now, let’s look at an example and then go through it:

type ConstructorArgs<T> = T extends {
    new (arg: infer A, args: any[]): any
} ? A : never;

Woah there, feller, this looks really complicated!

Yeah, it looks like that, but let’s break it down:

type ConstructorArgs<C> // a type, with a C as a type parameter, which will be our class
C extends // C must cover the minimum requirement of the following type
{ new (arg: infer A, ...args: any[]): any }

Here the new (arg: infer A, ...args: any[]): any means simply anything newable in JS. Anything that can take between 0 and infinite number of any type of arguments, returning anything.

The arg: infer A means that, if there is an argument list, use infer as a vacuum to suck up the first argument, and now the type param A is in scope, and we can use a conditional to return it up.

So now I can do something like this:

type ConstructorArgs<C> = C extends {
	new (arg: infer A, ...args: any[]): any
} ? A : never;

type CoWorkingConstructorConfig = ConstructorArgs<typeof CoWorking>; 
// why typeof - remember that typeof is literally giving us the information
// about a type

And now, I can get the exact type for the configuration object for this 3rd party class and I can always be sure that I am conforming to it:

type CoWorkingArgs = ConstructorArgs<typeof CoWorking>;


const andysConfig: CoWorkingArgs = {
  personName: 'Andrew Healey',
  startTime: new Date(),
  includeCoffee: true,
  isGuestSubscibe: false, // oh no, a typo, but now i'll get a highlight for it
}

Indexed access types

Ok, this one is pretty simple. We can use the object string notation with square brackets to accessing some parts of another type. Just like an object.

Lemme demonstrate real quick:

interface CSGOConsole {
	menuColors: {
		red: string;
		green: string;
		blue: string;
	}
	resolution: number;
}

type ConsoleColors = CSGOConsole['menuColors'];

// we can also do

type ConsoleColorsAndRes = CSGOConsole['menuColors' | 'resolution'];

// et voilis

Mapped types

Ok, so the time has come to combine the concepts we’ve learned about so far and enter the world of mapped types.

So, let’s start off with a simple problem to demonstrate why the hell do we even need mapped types, and then we’ll learn about what they are and what they do.

We’re still helping our grandma with her digital fruit business:

type Fruit = {
    name: string;
}

type FruitDict = {
    [k: string]: Fruit;
}

const myFruitMap: FruitDict = {
    'apple': {
        name: 'Red Granny Apple'
    }
};

Ok, so this is all good, and if we want to refer to the fruits, we can do:

myFruitMap.apple;

But here comes one problem:

// we can also do
myFruitMap.banana; // but banana does not exist

Now this could lead to errors, and since we’re running a business here, we want to be as specific as we can regarding what types of keys we can access on our fruit map and what kind of type the keys will give us back.

Enter mapped types. We’ll rename our FruitDict to a FruitRecord, since we’re now going to be shifting our thinking into the direction of working with a specific record of fruits.

We can approach with in this way:

type FruitRecord = {
    [Key in 'apple']: Fruit;
}

const myFruitMap: FruitRecord = {
    'apple': {
        name: 'Red Granny Apple'
    }
};

myFruitMap.apple; // yay 
myFruitMap.banana; // nay, this will now error

So now, we’ve introduced this FruitRecord type, and let’s break it down:

type FruitRecord = {
    [Key in 'apple']: Fruit;
}

In the [Key in 'apple'] part, we say, that the key here must be in this set of strings, which include 'apple'; and then we must get back a Fruit type.

If we want to make our banana work, we can modify our key to [Key in 'apple' | 'banana'] , and this will allow us to add and get bananas.

Anything else will not work, because it’s not part of the apple or banana limit we’ve introduced.

Let’s make our FruitRecord more abstract, so we can reuse it for other parts of our appplication:

type MyRecord<RecordKey extends string, T> = {
    [Key in RecordKey]: T
}

And let’s break it down:

  • type MyRecord<RecordKey extends string, T> - the type param called RecordKey extends string means “any key that must be at least the type string”
  • T is the type we’ll be expecting to get
  • [Key in RecordKey] - is the Key in the set of the RecordKey we’ve provided?
  • T at the end means that we’re expecting to get that type back

Now, we can use this abstract type four our FruitRecord

type Fruit = {
    name: string;
}

type MyRecord<RecordKey extends string, T> = {
    [Key in RecordKey]: T
}

const myFruitMap: MyRecord<'apple', Fruit> = {
    'apple': {
        name: 'Red Granny Apple'
    }
};

// We can also use it for other types

type Vegetable = {
    colour: string;
}

const myVeggieMap: MyRecord<'carrot', Vegatable> = {
    'carrot': {
        colour: 'orange'
    }
}

Using this approach, we can be much more specific about how we built our records, what keys can we use to access them, and that types of values we can expect to get back from them.

Remember index access types? Well we can make a very nice combination with them using mapped types.

Let’s imagine that we’re writing software for a Brazillian Jiu Jitsu tournament, and there are different divisions with their own rules. In BJJ, some belt ranks are not allowed certain submissions, because they are too dangerous to be done by beginner athletes, so we are tasked with writing a program, that will allow for the referee to pick which submissions are allowed for certain divisions.

First, let’s make some literals for the submissions:

type RNC = 'RNC';
type WristLock = 'Wrist lock';
type Triangle = 'Triangle';
type Armbar = 'Armbar';

And let’s put them all in a type:

type BJJSubmissions = {
    rnc: RNC;
    wristlock: WristLock;
    triangle: Triangle;
    armbar: Armbar;
}

Now, we want to limit what submissions we can give to the white belt guys (hello, I am a white belt), so let’s create a type that will allow us to limit what submissions we can do:

type PickRecord<T, Keys extends keyof T> = {
    [Key in Keys]: T[Key]
}

type WhiteBeltSubs = PickRecord<BJJSubmissions, 'rnc' | 'triangle'>;
// this will give us
/**
 type WhiteBeltSubs = {
    rnc: RNC;
    triangle: Triangle;
}
 **/
}

And let’s go through this too:

  • type PickRecord<T, Keys extends keyof T> - remember how keyof gives us a literal string union of a type’s keys? this means that Keys must be at least one of the members of this union
  • T is just the type
  • [Key in Keys]: T[Key] - if there is a match, we use the index access type and that’s what we expect

Pretty cool, right? We can now have control over our keys and our types, and we can use index access to determine what we can get back!

Template literal types

And this last one, which I think is pretty cool is using the string template literal types, just like a template string in JS:

type RappersPreNames = 'lil' | 'big' | 'don' | 'xxx';
type RandomWord = 'gun' | 'boi' | 'killer' | 'swag';

type PossibleRapNameCombos = `${RappersPreNames} ${RandomWord}`;

// and this will give us every possible combination of the two types:

/** type PossibleRapNameCombos = "lil gun" | 
 * "lil boi" | "lil killer" | "lil swag" 
 * | "big gun" | "big boi" 
 * | "big killer" | "big swag" 
 * | "don gun" | "don boi" 
 * | "don killer" | "don swag" |
 *  "xxx gun" | "xxx boi" 
 * | "xxx killer" | "xxx swag"

Conclusion

I hope this beefy post and the examples used has helped you understand some cool advanced types in TypeScript and that next time when you’re workign with code with a lot of angle brackets, and things like keyof , typeof and infer you can feel like “Oh holy shit, I actually know EXACTLY how this works!”

As always, it has been an absolute pleasure writing this post and using it not only as a rubber duck for me to solidify a new concept, but also for the rare chance someone discovers this blog someday and learns something new.

Until next time!


Written by Emil Mladenov - a slavic software developer who decided to use a blog as a digital rubber duck

I also have a podcast