Skip to content

Type Gymnastic - Part 7

Posted on:October 7, 2023 at 02:00 PM

This is part 7 of the series “Type Gymnastic”. If you haven’t already, go check out part 6. Let’s continue with more challenges today!

Table of contents

Open Table of contents

Intro

In this series of type challenges we do a walk through of all challenges that I used in my advent calender. We will continue from where we left off - challenge 18.

Conditionals, conditionals & conditionals 🤸

We are in the stage of this series where the challenges will get harder in difficulty, new concepts and tricks will get introduced. Let’s go through them now.

Challenge 18

You are given the type

type DeepPick = any;

And you want to construct a type similar to the Pick<T, K> built-in utility type, but also supports deep picking from an object. An example would be:

const records = {
	christmas: true,
	jingle: {
		first: "This time of Year",
		second: "Ella Wishes You A Swinging Christmas",
		third: "Sound of Christmas"
	},
	joy: {
		first: "Ella Wishes You A Swinging Christmas",
		second: "This time of Year",
		third: "Sound of Christmas",
		clueS: {
			s: 230,
		},
		clueE: {
			e: 59
		}
	},
} as const

type Example1 = DeepPick<typeof records, "christmas"> // Example = {readonly christmas: true}
type Example2 = DeepPick<typeof records, "joy.clueS.s" | "joey.clueE.e">
/**
 * Example2 =  joy: {
        clueS: {
            readonly s: 230;
        };
    };
} & {
    joy: {
        clueE: {
            readonly e: 59;
        };
    };

There are a lot of things to unpack in this challenge. Let’s start with the easiest case: The key sent to the generic is not a nested key. For example:

type Data = {
  a1: string;
  b1: number;
};

type T0 = DeepPick<Data, "a1">; // T0 = {a1: string}

For this we want to use mapped types to go through the type’s properties and see if the key exists on the type:

type DeepPick<T, K> = K extends keyof T
  ? {
      [Key in keyof T]: T[Key];
    }
  : unknown;

This should look pretty familiar if you have done other challenges. However, we need to add an extra conditional inside the mapped type to see that Key in keyof T extends the K generic, or else the resulting type will include all the properties regardless of what K is:

type DeepPick<T, K> = K extends keyof T
  ? {
      [Key in keyof T as Key extends K ? Key : never]: T[Key];
    }
  : unknown;

If we set the property’s key as never it will get ignored, so now the resulting type will only contain the relevant properties.

Since K can be a union type of different keys, let’s try to pass two keys:

type Data = {
  a1: string;
  b1: number;
};

type T0 = DeepPick<Data, "a1" | "b1">; // T0 = {a1: string} | {b1: number}

As seen above, the resulting type will be a union type of the different properties. What we want is to convert them into an intersection. If you did challenge 15, you already know about UnionToIntersection. I will not explain it here, so go check that out first if you haven’t.

Let’s create a helper type to improve the readability:

type DeepPick<T, K> = UnionToIntersection<Helper<T, K>>;

type Helper<T, K> = K extends keyof T
  ? {
      [Key in keyof T as Key extends K ? Key : never]: T[Key];
    }
  : unknown;

type UnionToIntersection<T> = (
  T extends any ? (args: T) => any : never
) extends (args: infer R) => any
  ? R
  : never;

We have now solved one of the use cases where the key sent to the generic is not a nested key!

Let’s continue on, looking at nested keys in the object. We can use template literal types to see if the key contains a ’.’, meaning it’s a nested key:

type DeepPick<T, K> = UnionToIntersection<Helper<T, K>>;

type Helper<T, K> = K extends `${infer F}.${infer L}`
  ? "Not implemented"
  : K extends keyof T
    ? {
        [Key in keyof T as Key extends K ? Key : never]: T[Key];
      }
    : unknown;

type UnionToIntersection<T> = (
  T extends any ? (args: T) => any : never
) extends (args: infer R) => any
  ? R
  : never;

The conditional K extends `${infer F}.${infer L}` checks whether or not the key is nested. If it is a nested key we can add another conditional to see if infer F is a key of the other generic T. If infer F is a valid key, we can use mapped types and recursively use our type. Let’s see:

type DeepPick<T, K> = UnionToIntersection<Helper<T, K>>;

type Helper<T, K> = K extends `${infer F}.${infer L}`
  ? F extends keyof T
    ? {
        [Key in F]: Helper<T[Key], L>;
      }
    : unknown
  : K extends keyof T
    ? {
        [Key in keyof T as Key extends K ? Key : never]: T[Key];
      }
    : unknown;

type UnionToIntersection<T> = (
  T extends any ? (args: T) => any : never
) extends (args: infer R) => any
  ? R
  : never;

That’s a lot of conditional types! The newly added mapped type

{
	[Key in F]: Helper<T[Key], L>
}

will use Helper recursively, however the generics passed to the type will be new: T[Key] will index the object to go “one level deeper”, and L are the nested keys, but not including the previous one because of: K extends `${infer F}.${infer L}`

So the solution to this challenge will be:

type DeepPick<T, K> = UnionToIntersection<Helper<T, K>>;

type Helper<T, K> = K extends `${infer F}.${infer L}`
  ? F extends keyof T
    ? {
        [Key in F]: Helper<T[Key], L>;
      }
    : unknown
  : K extends keyof T
    ? {
        [Key in keyof T as Key extends K ? Key : never]: T[Key];
      }
    : unknown;

type UnionToIntersection<T> = (
  T extends any ? (args: T) => any : never
) extends (args: infer R) => any
  ? R
  : never;

Challenge 19

You are given the type

type StringToNumber<S extends string> = any;

And you want to construct a type which takes a string as a generic, and converts it to a number type. If it’s a negative number, return the absolute value instead. An example would be:

type Example = StringToNumber<"42">; // Example = 42
type Example2 = StringToNumber<"-4">; // Example = 4

Using Typescript to convert strings to numbers… What? With some smart tricks, this is fully possible.

Let’s first check whether or not the string that is sent to the type contains a ’-’ prefix specifiying that it’s a negative number. We want to return the absolute value, so what we can do is to just remove the ’-’ in front of the string. We can use inferrence and template literal types to figure it out:

type StringToNumber<S extends string> = S extends `-${infer R}`
  ? "negative"
  : "positive";

Now if we were to use the type:

type A0 = StringToNumber<"-42">; // A0 = "negative"
type A1 = StringToNumber<"42">; // A1 = "positive"

If the number is negative, we want to use the absolute value. That means we can use the infer R from our conditional type and pass it to the type:

type StringToNumber<S extends string> = S extends `-${infer R}`
  ? StringToNumber<R>
  : "positive";

Now for the tricky part: How do we actually figure out that the string “42” is actually the number 42 in Typescript? In part 1 of this series we used an array constraint on a generic so we could access its length property. For this challenge, we want to use the length property of an array in order to convert a string to a number - all we have to do is to build an array of the same length as the generic! For this I will create a separate type to improve the readability:

type Enumerate<S extends string, A extends any[] = []> = any;

The type specified above takes our original string that is to be converted into a number, and a second generic which has a default value of an empty array. We will use this array type and the length property to see if our original string is the same as the length:

type Enumerate<S extends string, A extends any[] = []> = A extends {
  length: infer L;
}
  ? S extends `${L & number}`
    ? "The same"
    : "not the same"
  : never;

In the code above, S extends `${L & number}, checks whether or not the original string is the same as the length of the generic array. We need to specify the & number type inside the template literal type to force the infer L type to be a number type.

What we want to do next is to add extra elements to the generic array if the conditional type is falsy. This means that every time the conditional is falsy we add one extra element to the generic array, recursively use the Enumerate type, see if the new length of the array is the same as the original string etc. If the conditional type is truthy we can just return the infer L number:

type Enumerate<S extends string, A extends any[] = []> = A extends {
  length: infer L;
}
  ? S extends `${L & number}`
    ? L
    : Enumerate<S, [...A, 0]>
  : never;

Now back to the original StringToNumber type where we can use our new Enumerate type:

type StringToNumber<S extends string> = S extends `-${infer R}`
  ? StringToNumber<R>
  : Enumerate<S>;

Which is the solution to this challenge!

Challenge 20

You are given the type

type NorthBarAgeRestriction = any;

And you want to construct a type that accepts two numbers as generics, and returns a union type of that number range. An example would be:

type Example = NorthBarAgeRestriction<3, 6>; // Example = 3 | 4 | 5
type Example2 = NorthBarAgeRestriction<4, 0>; // Example2 = never

For this challenge, we want to use our trick from last challenge for enumerating arrays. However, let’s take a step back and first and look at arrays in Typescript. What happens if I have a type like this:

type A0<T extends any[]> = T[number];

What do you think will be the resulting type if I use the type i.e. type Example = A0<[1,2,3]>? The result will be a union type of the actual elements in the array. This means Typescript supports indexing arrays with an arbitrary type in order to get the array’s elements. Sounds like something that’s very useful for this challenge.

Another useful type for this challenge is the built-in Exclude type. If you are not familiar with Exclude, an example would be:

type UnionType = "a" | "b" | "c" | "d";
type ExcludedMembers = "b" | "c" | "f" | "e";
type T0 = Exclude<UnionType, ExcludedMembers>; // T0 = "a" | "d"

So an approach to solve this challenge is:

Let’s start by creating a type that creates arrays with a length based on the generic sent in. This is very similar to the previous challenge:

type Enumerate<
  N extends number,
  Acc extends number[] = [],
> = Acc["length"] extends N
  ? Acc[number]
  : Enumerate<N, [...Acc, Acc["length"]]>;

The difference between this Enumerate type and the one in the previous challenge is that here we already have the number type. We are not interested in converting a string into a number, but we are interested in the union type of the array’s elements. We are appending Acc["length"] to the array so every element will be incremented by one every time the type is recursively used. If you were to use this type now:

type T0 = Enumerate<3>; // T0 = 0 | 1 | 2
type T1 = Enumerate<5>; // T1 = 0 | 1 | 2 | 3 | 4

What happens if we use Exclude on these two union types shown above?

type T0 = 0 | 1 | 2;
type T1 = 0 | 1 | 2 | 3 | 4;

type T2 = Exclude<T1, T0>; // T2 = 3 | 4
// Notice the 'excluded members' (second generic parameter) is T0

If we return to our original type type NorthBarAgeRestriction = any, let’s add two generics that extend number, two enumerations types and finally an exclude type:

type NorthBarAgeRestriction<From extends number, To extends number> = Exclude<
  Enumerate<To>,
  Enumerate<From>
>;

type Enumerate<
  N extends number,
  Acc extends number[] = [],
> = Acc["length"] extends N
  ? Acc[number]
  : Enumerate<N, [...Acc, Acc["length"]]>;

Which is the solution to this challenge! Part 8 coming soon. Part 8

Relevant reading