Skip to content

Type Gymnastic - Part 2

Posted on:September 7, 2023 at 07:00 PM

This is part 2 of the series “Type Gymnastic”. If you haven’t already, go checkout part 1. Let’s continue with more challenges today!

Table of contents

Open Table of contents

Intro

In part 1 we introduced some type challenges that I used in my advent calender. We will continue from where we left off - challenge 4.

Getting warmed up

Challenges will progressively get more and more difficult.

Challenge 4

You are given the type

type PropertyType<T, K> = any;

and you want to construct a type which picks a single value from an object T based on K. An example would be:

type Example = PropertyType<{ a: number }, "a">; // Example = number

type Example2 = PropertyType<{ one: string; two: number }, "one">; // Example2 = string

The first generic T has to be an object which tells us that we want to add a type constraint to T and will make the following usage invalid:

type Example = PropertyType<number, "wrong">; // Type error!!

To add a constraint we extend T:

type PropertyType<T extends Record<any, any>, K> = any;

Record<K,V> is a built-in utility type that has two generics: K for all the valid keys and V for all the valid values. You can look at it like a basic JavaScript object:

type Names = "Joe" | "Bob";
const example: Record<Names, string> = {
  Joe: "Hello",
  Bob: "World",
};

We also want to add a constraint on K because only legal keys of T should be allowed. Meaning if we have the type

type Example = {
  a: number;
  b: string;
};

the valid keys for Example will be “a” and “b”. To get the valid keys for a type we use the keyof operator type:

type PropertyType<T extends Record<any, any>, K extends keyof T> = any;

Now we want PropertyType to equal whatever value K indexed on T is. For this we can use indexed access types:

type PropertyType<T extends Record<any, any>, K extends keyof T> = T[K];

But hold on, we have a bug! With the current type this is allowed with no type errors:

type Edge = PropertyType<[string, number], 0>; // Edge = string

type Case = PropertyType<string[], 42>; // Case = string

//The keyof operator for arrays and tuples will return number
type T = keyof string[];
const err: T = "abc"; // Type error
const correct: T = 9; // OK

An interesting case where tuples and arrays are recognized as a Record type. For this challenge we don’t want to allow tuples or arrays to be used. So how do we add more constraint to the generic T?

Typescript has a built-in type for all legal types of indexing keys called PropertyKey, and if you look into its definition you will see that type PropertyKey = string | number | symbol. Right now we use two any types for the Record type. We can try to add a less loose type to the Record<K, V> type to see if has any effect:

type PropertyType<T extends Record<PropertyKey, any>, K extends keyof T> = T[K];

Unfortunately, we will still not get a type error if we do this:

type StillAllowed = PropertyType<[number, string], 1>; // StillAllowed = string

So how do we actually stop allowing a tuple to be passed as a generic to our PropertyType type? We can try other built-in types representing an object type instead of Record:

type PropertyType<T extends Object, K extends keyof T> = T[K];
type PropertyType<T extends object, K extends keyof T> = T[K];

type StillAllowed = PropertyType<[number, string], 1>; // StillAllowed = string

Nope, we are still allowed to pass a tuple to the generic T.

So if we continue using the Record type, our next option is to try to change the any type we used as the type for property values. It seems like our constraint on the generic T is too loose. The constraint T extends Record<PropertyKey, any> means that T is allowed to be any type that can be indexed with a PropertyKey (string, number or symbol) and can have values of any type. Arrays and tuples can be indexed by numbers and contain values of any type, so they satify this constraint. This is why using tuples like [number, string] works here.

However, if we change the constraint for property values from any to unknown we can say that T is allowed to be any type that can be indexed with a PropertyKey and can have values of type unknown. This makes Typescript realise that strings and symbols cannot be used to index arrays and tuples.

More on why `unknown` works here TLDR: The `any` type is unsound and sometimes doesn't make sense when explaining some of its behavior.

If we inspect what types the Record<K,V> utility constructs when we use it we can see that

type A = Record<any, any>;
//Constructs this type
type A2 = {
  [x: string]: any;
};
const a: A = ["a"]; // This is OK

type B = Record<any, unknown>;
//Constructs this type
type B2 = {
  [x: string]: unknown;
};
const b: B = ["b"]; // Type error

When using unknown instead of any Typescript will realise that arrays and tuples cannot be indexed with a string and we get the type error Index signature for type 'string' is missing in type 'string[]'..

But if we change type of keys from any to be of type number, it will work with arrays and tuples:

type C = Record<number, unknown>;
//Constructs this type
type C2 = {
  [x: number]: unknown;
};
const c: C = ["c"]; // This is OK

But I think if you want to type arrays or tuples you should just stick with Array<T> or T[] :)

Which leads us to the solution to this challenge

type PropertyType<
  T extends Record<PropertyKey, unknown>,
  K extends keyof T,
> = T[K];

Challenge 5

You are given the type

type MyPick<T, K> = any;

and you want to construct a type which picks a set of properties from T based on K. This resembles challenge 1, however we want to be able to select multiple properties, not only one. An example usage could be:

type Prop = {
  a: string;
  b: number;
  c: string;
};
type Example = MyPick<Prop, "a" | "c">; // Example = {a: string; c: string}

type Illegal = MyPick<Prop, "a" | "d">; // Property 'd' does not exist on type Prop, will lead to type error!

As seen in the example above, you are able to select multiple properties from a type, but you are not allowed to pass in any key - they must be part of the type. And with that we can start defining the first constraint on the type:

type MyPick<T, K extends keyof T> = any;

now the keys, K, are only legal keys of T. Since we are allowed to pass in multipe keys to the type, we need to iterate over all of them. In JavaScript, this would be like a for-loop. In Typescript, you can use something called mapped types. The mapped type will iterate over the union of keys (the generic K) and create a new type. We use these mapped types when creating types that needs to be based on other types. Mapped types are based on the same syntax as index signatures:

type IndexSignature = {
  [key: string]: string;
};

// Usage
const example: IndexSignature = {
  a: "keys can be anything",
  xyz: "because of the index signature",
};

When iterating over the union of keys we can use the in keyword:

type MyPick<T, K extends keyof T> = {
  [P in K]: any;
};

// Usage
type Prop = {
  a: string;
  b: number;
  c: string;
};
type Example = MyPick<Prop, "a", "b">; // Example = {a: any, b: any}

We are now extracting all the right properties on the type, however the type for all of them will be any. To retrieve the original type for the properties we can use indexed access types: use P to index T (P will be “a” and “b” so what we essentially are doing is T["a"] and T["b"])

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

which is our solution to this challenge. Part 3 coming soon. Part 3

Relevant reading