The Trees The Fork Oak Day66 - Porting Tetris Attack to Typescript

Made significant progress toward porting my Tetris Attack clone to typescript

2019-04-16
Project Page

Todo

Today I spent some time porting many of the JavaScript files in the Tetris Attack clone to Typescript in order to get a better editing experience and to get some better type safety in my JavaScript code. I didn't finish the process as I ran out of time, but I got far enough in to decided that it was the right option because I already found a number of bugs just by annotating the types everywhere.

Process

Much of the process of porting to Typescript is just renaming the file extension from .js to .ts. Since Typescript is a relatively unobtrusive layer on top of standard JavaScript, much of the source remains unchanged. The only places I made changes were adding annotations to function arguments, reworking enum values, and moving class fields around.

Luckily the typescript environment I have setup in Spacemacs will tell me where I went wrong.

  class Match {
  constructor(matchBlocks) {
    this.blocks = matchBlocks; // Error: blocks does not exist on Match
    this.timer = 0; // Error: timer does not exist on Match

    for (let block of this.blocks) { // Error: blocks does not exist on Match
      block.state = state.MATCHED;
      dropBlock(block);
    }
  }

  update() {
    if (this.timer < clearDelay) { // Error: timer does not exist on Match
      this.timer++; // Error: timer does not exist on Match
    } else {

Here each of the lines I have commented had a simple red underline which when hovered over with my cursor told me that the various properties did not exist on the Match type. To fix this I simply added a field declaration at the top of the class.

  class Match {
  public blocks: Block[];
  public timer: number;

  constructor(matchBlocks: Block[]) {
    this.blocks = matchBlocks;
    this.timer = 0;

    for (let block of this.blocks) {
      block.state = state.MATCHED;
      dropBlock(block);
    }
  }

  update() {
    if (this.timer < clearDelay) {
      this.timer++;
    } else {

Enums

I also took this porting as an opportunity to use the better enum data types in Typescript over my hacky enum objects. The process was similarly simple, just rewriting the old object types:

  export const type = {
  WOOD: "Wood",
  ICE: "Ice",
  STONE: "Stone",
  LEAF: "Leaf",
  LAVA: "Lava",
  GOLD: "Gold",
  BANG: "Bang",
  GARBAGE: "Garbage"
};

Into the cleaner enum version:

  export enum BlockType {
  Wood,
  Ice,
  Stone,
  Leaf,
  Lava,
  Gold,
  Bang,
  Garbage
}

Static Fields

The Color and Vector types all have static properties on the class function which are set dynamically in the original source:

  export class Color {
  constructor(r, g, b, a = 1) {
    this.r = r;
    this.g = g;
    this.b = b;
    this.a = a;
  }
}

Color.white = new Color(1, 1, 1, 1);
Color.gray = new Color(0.5, 0.5, 0.5, 1);
Color.black = new Color(0, 0, 0, 1);
Color.clear = new Color(0, 0, 0, 0);

Swapping to Typescript required moving those static properties to field declarations within the class itself:

  export class Color {
  static white = new Color(1, 1, 1, 1);
  static gray = new Color(0.5, 0.5, 0.5, 1);
  static black = new Color(0, 0, 0, 1);
  static clear = new Color(0, 0, 0, 0);

  public r: number;
  public g: number;
  public b: number;
  public a: number;

  constructor(r: number, g: number, b: number, a = 1) {
    this.r = r;
    this.g = g;
    this.b = b;
    this.a = a;
  }
}

Type Bug Fixes

In the process of porting over I discovered a couple of bugs which were caught by the type checker. The first showed up as a weird error message:

Typescript Error Catch

Jumping to the definition of tint (using the typescript jump to definition support) brought me to this variable declaration:

  let tint = this.calculateColor(position.y);

Which when jumping to the calculate color function reveals this definition:

  calculateColor(centerY) {
  if (this.state === BlockState.Matched) {
    return new Color(1.5, 1.5, 1.5, 1);
  } else if (this.state === BlockState.Clearing) {
    let percentageDone = this.clearTimer / clearingTime;
    return new Color(1, 1, 1, 1 - percentageDone);
  } else if (this.state === BlockState.Cleared) {
    return Color.clear;
  } else if (this.state === BlockState.Spawning) {
    let gridBottom = gridCenter.y - gridDimensions.height / 2;
    let blockBottom = centerY - blockWidth / 2;
    let distanceFromBottom = blockBottom - gridBottom;

    if (distanceFromBottom >= 0) {
      if (this.state === BlockState.Spawning) {
        this.state = BlockState.Waiting;
      }
      return 1;
    }
    if (distanceFromBottom < -blockWidth) return 0;

    return new Color(1, 1, 1, (distanceFromBottom + blockWidth) / (blockWidth * 2));
  } else {
    return Color.white;
  }
}

This combined with the error message pointed out that for some branches of the calculateColor function I was returning a Color object, and for others I returned a number. It would have been a long while till I caught that bug without Typescript, so I consider this a huge win.

Browser Typescript

Typescript not only helps me by guaranteeing that my code is consistent. It also has a large library of built in browser types which match the standards definitions. When porting the touch functionality to Typescript, I discovered that the touchId property I was tracking on the pointer events was incorrect compared to the standards. Since the PointerEvent object is in the list of already defined types, I found out my error after annotating the handlePointerEvent function.

  function handlePointerEvent(e: PointerEvent) {
  if (!touchDown || touchId == e.pointerId) {
    touchId = e.pointerId;
    touchPosition = new Vector(e.clientX, screenSize.height - e.clientY);
    touchDown = e.pressure > 0;
  }
}

Import Weirdness

The only downside so far in porting is that Typescript is less tolerant to the trickery which Webpack plays to import things other than source files.

At the top of the webgl.ts file I previous had these imports which pulled in the shader code from the shader files at build time.

  import vert from './shaders/vert.glsl';
import frag from './shaders/frag.glsl';

Unfortunately Typescript complains here because it doesn't have a definition for the vert.glsl and frag.glsl shader files. To get around this I used the built in nodejs require function instead which has no such type constraints.

  const vert: string = require('./shaders/vert.glsl');
const frag: string = require('./shaders/frag.glsl');

Summary

Overall I find the process of porting to Typescript pretty satisfying because it gives me a stronger confidence that my code does what I think it does. More often than not I find myself wishing Typescript would support more complex and expressive types instead of feeling like the type system is holding me back. That this tool set also gives me better completion information is just a cherry on top.

Till tomorrow,
Kaylee