Using conditional types to port javascript code to typescript
2019-04-18
Project Page
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