Skip to content
This repository has been archived by the owner on Jun 7, 2023. It is now read-only.

Added feedback to TS blog #82

Open
wants to merge 1 commit into
base: andarist/index-by-type-draft
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 40 additions & 32 deletions content/posts/2022-03-28-mapped-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ category: entry
publishedAt: "2022-03-28"
---

Back in February, we released [typegen support in XState](./blog/introducing-typescript-typegen-for-xstate)). Typegen is a way for us to enhance a machine’s type definitions with some extra information and makes the final type much more strict and correct.
Back in February, we released [typegen support in XState](./blog/introducing-typescript-typegen-for-xstate). Typegen is a way for us to enhance a machine’s type definitions with some extra information and makes the final type much stricter and more correct.

Typegen works by injecting an extra property (`tsTypes`) into the machine configs. We have built some clever type plumbing in the `createMachine`’s signature to pass this injected information around and utilize it to produce the final types. Let’s explore one of the tricks we use there.
Typegen works by injecting an extra property - `tsTypes` - into the machine config. We have built some clever type plumbing in the `createMachine`’s signature to pass this injected information around and utilize it to produce the final types. In this article, we’ll explore one of the tricks we use there.

## Goals

One of the primary goals of this work was to provide type-safe (and inferred!) event types for our actions, delays, guards and services. Let’s call this group “implementations.”

Take a look at the following simple example:
Take a look at the following example:

```ts
type IncrementEvent = { type: "INC"; value: number };
Expand Down Expand Up @@ -46,26 +46,26 @@ createMachine(
};
}),
},
}
},
);
```

Ideally, the `event` parameter in the `increment` action would be typed automatically as `IncrementEvent`, making the `event.value` property access safe and sound. However, our input `TEvent` is a union of all the events a machine can accept. We need to somehow narrow the union down to its specific members for all the implementation types.
Ideally, the `event` parameter in the `increment` action would be typed automatically as `IncrementEvent`. That's because the `increment` action can only ever be called by the `INC` event. With that inference, the `event.value` property access would be safe and sound. However, our input `TEvent` is a union of all the events a machine can accept. We need to somehow narrow the union down to its specific members for all the implementation types.

You may think this example is simple, but in XState:

- entry and exit actions are also called when a machine transitions between states
- the same action type can also appear in multiple places in the machine’s config.

The only way to properly narrow the input union is to gain the knowledge about the full graph of this machine and its edges. Usually this problem can be solved using standard computer science algorithms for graph traversals, a fairly easy task in a language with support for loops, local variables, and other constructs. The solution is much less obvious if the only language at our disposal is TypeScript - to pull this off, we’d have to resolve all defined transitions at the type-level.
The only way to properly narrow the input union is to **gain the knowledge about the full graph of this machine**. Usually this problem can be solved using standard computer science algorithms for graph traversals, a fairly easy task in a language with support for loops, local variables, and other constructs. The solution is much less obvious if the only language at our disposal is TypeScript’s type system - to pull this off, we’d have to resolve all defined transitions at the type-level.

But [Devansh](https://twitter.com/devanshj__) proved to us that this actually might be possible, but the type-level code responsible was **very advanced** and hard to grok. The problem with solutions like this is that not many people can actually read this level of type wizardry, and even fewer people can meaningfully edit and maintain it over time.
[Devansh](https://twitter.com/devanshj__) proved to us that this actually might be possible, but the type-level code responsible was **very advanced** and hard to grok. The problem with solutions like this is that not many people can actually read this level of type wizardry, and even fewer people can meaningfully edit and maintain it over time.

This is why we settled on type generation, a simplified solution that still needs more than a pinch of type tricks to work correctly.

## Static shape requirement

One critical element to understand is that the injected information has to have a *static* shape. We can’t inject any type that expects generic type parameters because such unbounded types are not allowed in TypeScript:
One critical element to understand is that the injected information has to have a _static_ shape. We can’t inject any type that expects generic type parameters because such unbounded types are not allowed in TypeScript:

```ts
type Resolve<T> = T;
Expand All @@ -75,9 +75,9 @@ createMachine({
});
```

The static shape requirement is somewhat limiting because such an injected type can’t simply *accept* the usual generics found in many of our types, such as `TContext` and `TEvent`.
The static shape requirement is somewhat limiting because such an injected type can’t simply _accept_ the usual generics found in many of our types, such as `TContext` and `TEvent`.

Another critical requirement of our design is that the user would still specify those generics at the `createMachine` call. So we had to figure out how to generate some static shape of an object that could later be *resolved*/bound with the provided generics in a custom way.
Another critical requirement of our design is that the user would still specify those generics at the `createMachine` call. So we had to figure out how to generate some static shape of an object that could later be _resolved_/bound with the provided generics in a custom way.

We’ve settled on a shape resembling something like this:

Expand All @@ -94,55 +94,55 @@ interface Typegen0 {
}
```

The key takeaway here is that we’re creating a map of, for example, action types to event types that can cause those actions to be called. In the example above, we don’t need access to any generics or any other information on the `TEvent` union.
The key takeaway here is that we’re creating a map of, for example, action types to event types that can cause those actions to be called - `eventsCausingActions`. In the example above, we don’t need access to any generics or any other information on the `TEvent` union.

It’s also worth noting here that we can freely use `never` in places where no extra information is available because `never` is assignable to everything.

## Resolving (basics)

The next step was realizing that we could grab the input generics (such as `TEvent`) and the injected typegen information and *stitch* them together. Since they’re all generic parameters and TypeScript allows us to *process* types with other types, we can create a type just for stitching them into a single type. This type won’t exist anywhere on the outside but will make our lives way easier internally.
The next step was realizing that we could grab the input generics (such as `TEvent`) and the injected typegen information and _stitch_ them together. Since they’re all generic parameters and TypeScript allows us to _process_ types with other types, we can create a type just for stitching them into a single type. This type won’t exist anywhere on the outside but will make our lives way easier internally.

Doing this in XState looks something like this:
Doing this in XState looks something like the example below. I've simplified it a little for clarity.

```ts
declare function createMachine<
TContext,
TEvent extends { type: string },
TTypegen
TTypegen,
>(
config: MachineConfig<TContext, TEvent, TTypegen>,
implementations: MachineImplementations<
TContext,
ResolveTypegen<TEvent, TTypegen>
>
>,
): void;
```

Notice above that we pass existing type parameters to `ResolveTypegen`, and the result is just passed to `MachineImplementations`.

## Indexing

Once we’re finally providing types for our implementations, we will need a way to narrow down the input union to specific events. Basically, we’ll need a type in place of `GetJustIncrementEvent`:
Once we’re finally providing types for our implementations, we will need a way to narrow down the input union to specific events. Basically, we’ll need a type in place of `ExtractIncrementEvent`:

```ts
{
actions: {
incremenet: (ctx: TContext, ev: GetJustIncrementEvent<TResolvedTypegen>)
incremenet: (ctx: TContext, ev: ExtractIncrementEvent<TResolvedTypegen>)
}
}
```

Here, I figured out that I don’t want to *search through* the `TResolvedTypegen` for each provided implementation separately. Remember that we might be dealing with a huge machine that accepts many events and has a lot of implementations. So it seemed like we should also think about the performance of our types at this point.
Here, I figured out that I don’t want to _search through_ the `TResolvedTypegen` for each provided implementation separately. Remember - we might be dealing with a huge machine that accepts many events and has a lot of implementations. So it seemed like we should also think about the _performance_ of our types at this point.

At this stage, we could start thinking in more classic programming approaches; for instance, how do I search through an array of items and select interesting items?

```js
const matchingEvents = allEvents.filter((event) =>
matchingEventTypes.includes(event.type)
matchingEventTypes.includes(event.type),
);
```

The simple algorithm above has quadratic complexity; it needs to compare all existing items with all criterion items. Usually, this complexity wouldn’t be an issue, but as previously mentioned, we might want to execute this algorithm many times.
The simple algorithm above has **quadratic complexity**; it needs to compare all existing items with all criterion items. Usually, this complexity wouldn’t be an issue, but as previously mentioned, we might want to execute this algorithm many times.

Optimizing this algorithm is relatively easy, especially since `allEvents` are shared for the whole machine. We just need to create a map of all the events upfront and use it to map our `matchingEventTypes`:

Expand All @@ -151,7 +151,7 @@ const eventMap = new Map(allEvents.map((event) => [event.type, event]));
const matchingEvents = matchingEventTypes.map((type) => eventMap.get(type));
```

The optimization above has linearithmic time complexity (`O(n log n)`!), which is much better for performance, and it turns out we can implement this in TypeScript for our needs.
The optimization above has **linearithmic time complexity** (`O(n log n)`!), which is much better for performance, and it turns out we can implement this in TypeScript for our needs.

Let’s take a look at what we roughly want to achieve and why:

Expand Down Expand Up @@ -222,7 +222,7 @@ and try to translate it somewhat literally to TypeScript:

```ts
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
x: infer R
x: infer R,
) => any
? R
: never;
Expand All @@ -234,7 +234,7 @@ type IndexByType<T extends { type: string }> = UnionToIntersection<

The solution above depends on one hell of a scary `UnionToIntersection` type. Read [@ddprrt](https://twitter.com/ddprrt)’s super well-explained article on [TypeScript: Union to intersection type](https://fettblog.eu/typescript-union-to-intersection/) to understand how it works.

All we need to know here is that the example above transforms a union to intersection through some sorcery. Before we hand our intermediate result to that type, we first distribute the input union and create small object types out of it. They’re small because each has a single property, created based on just one union member. This happens thanks to the distribution, which kicks in when our conditional type uses a *naked* type parameter.
The example above transforms a union to intersection through some sorcery. Before we hand our intermediate result to that type, we first distribute the input union and create small object types out of it. They’re small because each has a single property, created based on just one union member. This happens thanks to the distribution, which kicks in when our conditional type uses a _naked_ type parameter.

This solution creates a correct but somewhat suboptimal type because it’s displayed in our little example as below:

Expand Down Expand Up @@ -268,7 +268,7 @@ type Result = {
};
```

There is no real difference between those two results; they should be functionally equivalent. However, for readability purposes, you might prefer to use the *computed variant*, as the string representation of this type might end up printed in TS tooltips when we make a mistake or simply hover over some type that refers to it.
There is no real difference between those two results; they should be functionally equivalent. However, for readability purposes, you might prefer to use the _computed variant_, as the string representation of this type might end up printed in TS tooltips when we make a mistake or simply hover over some type that refers to it.

Honestly, I’ve not reached for the showcased implementation so far. I’ve just shared it as a comparison to the “JavaScript implementation” and for fun 😉

Expand All @@ -280,6 +280,8 @@ type IndexByType<T extends { type: string }> = {
};
```

## Handling unions within unions

One of our users pointed out that this implementation falls short when one of the union members has a type property that itself is a union. We could rewrite our example types as follows:

```ts
Expand Down Expand Up @@ -309,13 +311,13 @@ The important thing to understand here is how conditional types work, their dist

A conditional type usually checks if a given type is a subtype of another type (while potentially inferring some types along the way). In other words, the conditional type checks a condition, based on the subtype-supertype relationship between the two types, and selects the respective branch based on the check’s result.

Distributivity is a hidden property of a conditional type that causes it to be evaluated differently. When a type is distributive, the condition is checked for each union member separately, and the result is a union of those per-member results. The `Extract` type is distributive because it operates on a *naked* `T` type; this is best illustrated with a non-distributive variant of `Extract`:
Distributivity is a hidden property of a conditional type that causes it to be evaluated differently. When a type is distributive, the condition is checked for each union member separately, and the result is a union of those per-member results. The `Extract` type is distributive because it operates on a _naked_ `T` type; this is best illustrated with a non-distributive variant of `Extract`:

```js
type NonDistributiveExtract<T> = [T] extends [U] ? T : never
```

In the type above, the `T` has been *wrapped* in tuple type is thus no longer naked; it has some kind of *modifier* around it. Note that the modifier could be an index access (`T['property']`), a type alias instantiation (`TypeAlias<T>`) and more.
In the type above, the `T` has been _wrapped_ in tuple type is thus no longer naked; it has some kind of _modifier_ around it. Note that the modifier could be an index access (`T['property']`), a type alias instantiation (`TypeAlias<T>`) and more.

So how should we think about assignability object types? A subtype of an object type is a type that has a _more_ specific property or/and additional properties:

Expand All @@ -330,7 +332,7 @@ type IsAssignable3 = { type: string; value: number } extends { type: string }
// ^? 1
```

The following example shows what happens during one of the `Extract` *iterations* (per member check) with our example type:
The following example shows what happens during one of the `Extract` _iterations_ (per member check) with our example type:

```ts
type IsAssignable4 = { type: "INC" | "DEC"; value: number } extends {
Expand Down Expand Up @@ -371,6 +373,8 @@ type IndexByType<T extends { type: string }> = {
};
```

<!-- TODO - What is T extends any doing here? -->

Now we get the desired outcome for the following input type:

```ts
Expand Down Expand Up @@ -399,18 +403,22 @@ type IndexByType<T extends { type: string }> = {
};
```

<!-- TODO: maybe restructure this so it doesn't use single-letter types? -->

Using key remapping in mapped types is much simpler and already behaves how we want. It doesn’t have any problems when it comes to handling properties with union types!

Here’s what’s happening above in plain English:
Here’s what’s happening above in plain English:

1. iterate through the input union (`T`)
2. assign the current member to `E`
3. set the key to its type (`E["type"]`) 4. and set the value to the current member itself (`E`)
3. set the key to its type (`E["type"]`)
4. and set the value to the current member itself (`E`)

What’s really neat is that we get access to a non-string type that we can use to compute the value as long as we set the key to a string type, which was not possible before this feature was implemented. We could only iterate over the strings, as the current element of the iteration was also always used as the key. In the past, we had to retrieve any *non-primitive* type using our current key from some other type; this is basically what we’ve done in the previous versions of this `IndexByType` type.
What’s really neat is that we get access to a non-string type that we can use to compute the value as long as we set the key to a string type, which was not possible before TS added this feature. We could only iterate over the strings, as the current element of the iteration was also always used as the key. In the past, we had to retrieve any _non-primitive_ type using our current key from some other type; this is basically what we’ve done in the previous versions of this `IndexByType` type.

Note: we can also use numbers and symbols as the key’s type. I’ve omitted this from the explanation for brevity.

One small issue with this solution is that values still have those string unions as types. This isn't incorrect but perhaps could be viewed as a little confusing. Consider the situation from before:
One small issue with this solution is that values still have those string unions as types. Consider the situation from before:

```ts
{
Expand Down Expand Up @@ -507,7 +515,7 @@ interface ResetEvent {
type MyEvent = { type: "INC" | "DEC"; value: number } | ResetEvent;
```

We would still get the same result as the previous example with a union defined in this way. Using `Compute` always produces *anonymous types* so all we can see is their content being *inlined*. But here, we gave a name to one of the events, `ResetEvent`. And it would be great if we could preserve the name.
We would still get the same result as the previous example with a union defined in this way. Using `Compute` always produces _anonymous types_ so all we can see is their content being _inlined_. But here, we gave a name to one of the events, `ResetEvent`. And it would be great if we could preserve the name.

So when do we need to use `Compute`? Only when the `T[K]["type"]` **isn’t exactly** matching the `K`. Let’s try the adjusted version of the `NarrowType` then:

Expand Down