The Trees The Fork Oak Day68 - Conditional Types in Typescript

Using conditional types to port javascript code to typescript

2019-04-18
Project Page

Todo

Today I finished porting all of the source files in my Tetris Attack remake to Typescript. Most of the work was similar to my last post on the topic, but I did run into an interesting type system issue which I figured would be interesting to talk about.

Javascript Version

The function in question was the gridToScreen function used for transforming block location information from grid space to screen space. The original function looked like this:

  export function gridToScreen(location) {
  let result = {};

  if (location.position) {
    let blocksTopLeft = new Vector(
      gridCenter.x - gridDimensions.width / 2,
      gridCenter.y - gridDimensions.height / 2 + blockPixelAdvancement);

    result.position = blocksTopLeft.add(
      location.position.multiply(blockWidth)
        .multiplyParts(new Vector(1, -1)));
  }

  if (location.dimensions) {
    result.dimensions = location.dimensions.multiply(blockWidth);
  }

  return result;
}

Its a pretty simple function which creates a return object with transformed variables depending upon which properties exist on the incoming object. This pattern is somewhat common in dynamic programming languages because you can group a series of operations that are done sometimes together or sometimes separately into one unit. Unfortunately with traditional type systems this can be difficult to handle properly.

Naive Approach

Standard type annotations for the argument might look like this:

  interface Location {
  position?: Vector,
  dimensions?: Vector
}

export function gridToScreen(location: Location) {
  let result = {} as Location;

  if (location.position) {
    let blocksTopLeft = new Vector(
      gridCenter.x - gridDimensions.width / 2,
      gridCenter.y - gridDimensions.height / 2 + blockPixelAdvancement);

    result.position = blocksTopLeft.add(
      location.position.multiply(blockWidth)
        .multiplyParts(new Vector(1, -1)));
  }

  if (location.dimensions) {
    result.dimensions = location.dimensions.multiply(blockWidth);
  }

  return result;
}

This compiles fine, but we run into problems if we want to use properties in the output of the function.

  for (let coveredSlot of this.coveredSlots.values()) {
  let renderInfo = gridToScreen({
    position: coveredSlot,
    dimensions: Vector.one
  });

  image({
    imageUrl: garbageImages.Clear,
    center: Vector.topLeft,
    ...renderInfo
  });
}

For example in the render function on the ClearAnimation class we get a compiler error complaining that the image function argument does not contain the position and dimensions properties. The compiler has no way to guarantee that the properties on renderInfo are actually there.

Set-ish Types

To fix this issue and help the type system along we need to take advantage of some more advanced type system features in the recent versions of Typescript. But first, some background terminology.

Typescript contains two concepts that have names related to Set operations, but are a bit misleading: Union and Intersection types. The Union of two types in Typescript produces a new type containing all of the properties of each of the types combined. Similarly the intersection of two types produces a new type with either the properties of the first object or the properties of the second.

The union type makes good sense since a union of two sets contains all of the elements that exist in one of either of the sets.

  (A, B, C) Union (C, D, E) Equals (A, B, C, D)

The intersection type is weird though because a valid element inhabiting the intersection between two overlapping types has no guarantee about what properties exist on it. In normal set theory terms:

  (A, B, C) Intersection (C, D, E) Equals (C)

But in typescript it means that the final object could be the first type or the second type. It could be me, but I find this somewhat confusing.

Dependent er... Conditional Types to the Rescue

Luckily modern Typescript gives a way to define our own versions of these ideas. In my case I need a type which truly is the "intersection" of two types which has the common properties between the two. To do this I use type conditions to specify the constrain I have in mind.

  export type Common<A, B> = {
  [P in keyof A & keyof B]: A[P] | B[P]
};

The syntax is a little bit weird, but in English this says the following:

  Define the Common of two types, A and B as
A new type with 
Keys such that every key P exists in both A and B,
And values that are either the type of A[P] or B[P]

In summary, do something closer to the Set union of two bags of properties. The last bit of useful information before I show the final solution is the existence of a Partial type which is another bit of fancy Typescript type shenanigans which just takes a type and creates a new type where each of the properties are optional. It is defined as such:

  export type Filter<T> = {
  [P in keyof T]?: T[P]
};

In this form you can see the structure of a mapped type or conditional type a little easier. Its just a way to specify properties in terms of the properties on other types.

Better Type Annotations

With our new found fancy types in hand, the more expressive version of gridToScreen type annotations is pretty simple:

  interface Location {
  position: Vector,
  dimensions: Vector
};
export function gridToScreen<T extends Partial<Location>>(location: T) {
  let result = {} as Common<T, Location>;

  if ("position" in location) {
    let blocksTopLeft = new Vector(
      gridCenter.x - gridDimensions.width / 2,
      gridCenter.y - gridDimensions.height / 2 + blockPixelAdvancement);

    result.position = blocksTopLeft.add(
      location.position.multiply(blockWidth)
        .multiplyParts(new Vector(1, -1)));
  }

  if ("dimensions" in location) {
    result.dimensions = location.dimensions.multiply(blockWidth);
  }

  return result;
}

First step was to specify that the properties on the input argument are optional using the Partial mapped type. Then the type of the result is simply the common properties from the passed in argument and the location type itself. So if the object passed in only contains the Position property, then the result type will only contain Position as well since the only common properties are Position.

The only slightly confusing bit was that I had to modify the if statements to use the in operator to check for the existence of the properties so the type system can be confident that the position property actually exists on the argument at runtime.

And thats really it! My ClearAnimation render function doesn't need changed at all because the Types provide proof that the correct arguments are available when I expect them to be. I'm incredibly pleased that the type system in Typescript continues to get more and more expressive. This is just the smallest baby step toward more complicated proofs in software, but any progress is commendable. Heres to hoping for full fledged Pi types in the future!

Till tomorrow,
Kaylee