Skip to content

What I Learned From Total TypeScript - Type Transformations

Posted on:April 4, 2023 at 04:48 PM

I’ve been using TypeScript professionally for multiple years now, and I took Matt Pocock’s Total TypeScript course because I wanted to level up my TypeScript skills by filling in the gaps of my knowledge.

Before diving in, I want you to know that this article is not sponsored. I’m just enjoying the course, and I want to share what I’ve learned so far.

Total TypeScript is a TypeScript course consisting of 3 workshops:

  1. Type Transformations
  2. TypeScript Generics
  3. Advanced TypeScript Patterns

I finished the first part of the workshop called “Type Transformations.”

In this article I’ll document some of the things I learned from the first workshop. This article started as a reference for myself, but I decided to share it on my blog. And as they say: to teach to learn twice.

I’ve added TypeScript Playground links to the examples so you can play with the code if you like.

Template literal types

I knew these existed, but to be honest, I’ve never used them.

The pattern matching that typescript provides you when you have someting like this is great!

type Bread = "croissant" | "sandwich" | "baguette";
type Filling = "cheese" | "ham" | "salami";

type Options = `${Filling} ${Bread}`;

This produces all available combinations of breads and fillings as string literal types.

See the template literal types in the TypeScript Playground

The infer keyword

The infer keyword is used to extract a type out of another type.

I now have a much better understanding of how infer works, and after completing the first few challenges I found myself using infer even though I always forgot to use it before taking the course.

One thing I really liked, was this solution that combines a union type and the infer keyword:

type GetParserResult<T> = T extends
  | (() => infer TResult)
  | { parse: () => infer TResult }
  | { extract: () => infer TResult }
  ? TResult
  : never;

Which is a huge readability improvement over something like this with many more ternary operators:

type GetParserResult<T> = T extends () => infer TResult
  ? TResult
  : T extends { parse: () => infer TResult }
  ? TResult
  : T extends { extract: () => infer TResult }
  ? TResult
  : never;

See both examples in the TypeScript Playground

Distributive Conditional Types

TypeScript behaves differently in a generic context compared to a non-generic context in conditional types. As the name suggests, you are in a generic context when you are using generics. Outside of a generic context, TypeScript will compare unions as their complete values against other union types.

This means that given the following code, AppleOrBanana will always be never because "apple" | "banana" | "orange" is being compared to "apple" | "banana" as a whole, and as a whole the types are not equal.

type Fruit = "apple" | "banana" | "orange";

type AppleOrBanana = Fruit extends "apple" | "banana"
  ? "apple" | "banana"
  : never;

It’s comparing the full union type against the other full union type.

In a generic context, TypeScript will iterate (like a loop) over each value of the union type individually instead.

You can fix this problem and have TypeScript compare individual values by introducing a generic context with infer T as follows:

type Fruit = "apple" | "banana" | "orange";

type AppleOrBanana = Fruit extends infer T
  ? T extends "apple" | "banana"
    ? T
    : never
  : never;

The T here is acting as the iterator now, solving the problem of the complete union types being compared against eachother.

Now it makes the following comparisons:

This results in the type "apple" | "banana".

If you replace "apple" with something like "pear" as follows, the result of AppleOrBanana will be just"banana":

type Fruit = "pear" | "banana" | "orange";

If Fruit is a union type like "pear" | "blueberry" | "orange" that doesn’t include apple or banana, then the type AppleOrBanana will be never.

See these examples in the TypeScript Playground.

Mapped types

I’ve used mapped types before, but I understand them a lot better now. By combining my newfound knowledge of template string types I learned that you can modify the keys of a mapped type like this:

interface Person {
  name: string;
  age: number;
  location: string;
}

type Getters = {
  [Property in keyof Person as `get${Capitalize<Property>}`]: () => Person[Property];
};
/* this becomes:
{
  getName: () => string;
  getAge: () => number;
  getLocation: () => string;
}; */

See the above example in the TypeScript Playground

Another neat trick I learned about mapped types, is that you can filter out keys dynamically as well:

type StringWithId = `${string}${"id" | "Id"}${string}`;
type OnlyKeysWithId<T> = {
  [K in keyof T as K extends StringWithId ? K : never]: T[K];
};

type Example = {
  id: string;
  stuff: string;
  regionId: string;
};

type Result = OnlyKeysWithId<Example>;
/* this becomes:
{
  id: string;
  regionId: string;
} */

You can try this in the TypeScript playground.

Mapping over discriminated unions

If you want to map over discriminated unions, you need a way to extract based on the discriminator key (the value that makes each option identifiable):

type Extracted = Extract<MyUnion, { discriminatorKey: "something-unique" }>;

You can then use this more generically:

type Route =
  | {
      route: "/";
      search: {
        page: string;
        perPage: string;
      };
    }
  | { route: "/about"; search: {} }
  | { route: "/admin"; search: {} }
  | { route: "/admin/users"; search: {} };

type FindByRoute<R> = Extract<Route, { route: R }>;

//
type RoutesObject = {
  [R in Route["route"]]: FindByRoute<R>["search"];
};
// by using R in Route["route"] you get a union of the route values
// "/" | "/about" | "/admin" | "/admin/users"
/* this becomes:
{
    "/": {
        page: string;
        perPage: string;
    };
    "/about": {};
    "/admin": {};
    "/admin/users": {};
}
*/

You can try this in the TypeScript playground

But you can greatly simplify this by iterating over the entire Route object instead of the keys, while accessing the route property as the key:

type RoutesObject = {
  [R in Route as R["route"]]: R["search"];
};

The result is the same.

Turn an object into a union type of tuples

You can put a union type in square brackets at the end of an object type to extract the values of an object:

interface Values {
  email: string;
  firstName: string;
  lastName: string;
}

type Keys = keyof Values extends "email" | "firstName" | "lastName"
  ? true
  : false;
// true, so
// keyof Values = email" | "firstName" | "lastName"

type MappedType = {
  [K in keyof Values]: [K, Values[K]];
};
/*
{
    email: ["email", string];
    firstName: ["firstName", string];
    lastName: ["lastName", string];
}
*/

type ValuesAsUnionOfTuples = {
  [K in keyof Values]: [K, Values[K]];
}[keyof Values];
/* this becomes:
["email", string] | ["firstName", string] | ["lastName", string] */

See this in the TypeScript Playground

Blowing my own mind with the challenges

If you want to take the course and to the challenges yourself you can still safely read the following section. I won’t cover all the challenges, and I’ll only show my solutions here, not the solutions provided by Matt.

Don’t worry if you think some of these types are overwhelming. Some of them still were a bit overwhelming to me right after I wrote them.

The following was my solution to a challenge where you had to extract query parameters from a string. The proposed solution by Matt was a lot shorter, but still I was surprised I was able to write a solution for a TypeScript type so complex, as I was not able to do things like this before taking the course.

import { S } from "ts-toolbelt";

type UserOrganisationPath = "/users/:id/organisations/:organisationId";

type QueryParam = `:${string}`;
type GetQueryParams<StringUnion> = StringUnion extends QueryParam
  ? S.Split<StringUnion, ":">[1]
  : never;

type ExtractPathParams<
  TString extends string,
  TParts = S.Split<TString, "/">[number]
> = {
  [K in GetQueryParams<TParts>]: string;
};

type Result = ExtractPathParams<UserOrganisationPath>;
/*
{ id: string; organisationId: string }
*/

Try it in the TypeScript Playground

Another challenge I liked was taking an interface and turning it into a mutually exclusive union type. Here is my solution:

interface Attributes {
  id: string;
  email: string;
  username: string;
}

type MutuallyExclusive<T extends Record<string, any>> = {
  [K in keyof T]: {
    [key in K]: T[K];
  };
}[keyof T];

type ExclusiveAttributesResult = MutuallyExclusive<Attributes>;
/*
| {
  id: string;
}
| {
  email: string;
}
| {
  username: string;
}
*/

Try it in the TypeScript Playground

Conclusion

Even after using TypeScript for multiple years and feeling comfortable doing so, there are probably many things you can still learn to improve the types in your projects.