Monday, February 15, 2016

Using TypeScript Decorators to Assert State

The Project

My project is an event driven and ajax heavy HTML5 Canvas based web app written in TypeScript.

The Problem

Recently, I started encountering some bugs caused by incorrect execution order.

For example, my app was trying to render to the canvas from "view A" after "view B" was shown.

Due to the heavily async nature of the app, it was not at all obvious why the code was being called in an unexpected order.

Solution Attempt 1: inline state assertions

My initial step was to add state to my primary view.


enum EUIState {
    UNDEFINED = 0,
    CONSTRUCTED = 1,
    INITIALIZED = 2,
    STARTED = 4,
    MOVING_BACK_IN_TIME = 8,
    MOVING_FORWARD_IN_TIME = 16,
    STOPPED = 32
}

class PrimaryView {
    private state: EUIState = EUIState.UNDEFINED;
}  


Then I set state transitions in my code...

e.g.


class PrimaryView {
    private state: EUIState = EUIState.UNDEFINED;
    private animationFrameHandle: number = null;
    constructor() {
        this.state = EUIState.CONSTRUCTED;
    }
    public start(): void {
        this.state = EUIState.STARTED;
        this.animationFrameHandle = window.requestAnimationFrame(() => this.onRender());
    }
    private onRender() {
        //render canvas here
    }
}  



Then, in a few of my methods, I added some assertions...

e.g.


    private onRender() {
        if (this.state !== EUIState.STARTED && this.state !== EUIState.MOVING_BACK_IN_TIME && this.state !== EUIState.MOVING_FORWARD_IN_TIME)
            throw new Error("Invalid stare transition");
        //render canvas here
    }


Ugly.

This was ugly ugly code that would be sprinkled throughout my app, and that I would have to pull out later by hand.

It was time to whip out the TypeScript decorators.

Solution Attempt 2: TypeScript decorators

I had written C# Attributes and used Java Annotations in the past, so I was keen on seeing what I could do with TypeScript.

I found valuable info here, in depth info here and a bare bones spec here.

I won't rehash the contents of those pages in this post, but let my inline comments tell the story.


// This is a decorator: a plain old JavaScript function, taking an arbitrary number of arguments.  
// note: It could take a fixed number of args, even 0 args.  The use of Rest Parameter (...states) here is case specific.
function state(...states: EUIState[]) {

    // This is the pattern you use with method decorators.  In this pattern you return a function that takes
    // some parameters describing the function call instance and returns a TypedPropertyDescriptor
    return (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) => {
        // Here we save a reference to the original method that was decorated by @state
        var originalMethod = descriptor.value; 

        // NOTE: Do not use arrow syntax here. Use a function expression in 
        // order to use the correct value of `this` in this method.
        // 
        // We are overriding the original Method to inject our own code and then call the original Method.
        descriptor.value = function (...args: any[]) {
            // This is the bit of code I wanted to inject before the call to the original Method.
            // It checks to make sure the state of the class is one of the valid states for the method.
            if (states.indexOf(this.UIState) === -1) {
                console.error("Unexpected state (" + EUIState[this.UIState] + ") in " + propertyKey);
            }
            //Call the original Method
            var result = originalMethod.apply(this, args);
            return result; // return the result of the original method
        };

        return descriptor;
    };
}



At this point, I added @state decorators to all my methods.

e.g.


    @state(EUIState.STARTED, EUIState.MOVING_BACK_IN_TIME, EUIState.MOVING_FORWARD_IN_TIME)
    private onRender() {
        //render canvas here
    }


As you can see, the syntax in much much cleaner, and I can remove the @state assertion easily either
by a mass search and replace or by the addition of a simple build step.

The Result

These @state annotations allowed me to quickly find where I had made logic errors resulting in execution order bugs.

Lessons learned

Just because I did't have a bunch of explicit state variables hanging around didn't mean my app wasn't stateful (Remember, I added the EUIState enum after the app was already failing, and I never read the PrimaryView.state variable outside of the decorators).

I had written a code base that was implicitly stateful.

So, ask yourself, "Is my code implicitly stateful?". If the answer is yes, then, just as with unit tests, you can gain some peace of mind (and thus fear change less) if you make those state transitions explicit and add debug time assertions to your code via TypeScript decorators.

Afterthoughts

def: assertion decorator
An assertion decorator is a decorator that is used to make run-time assertions and may be removed and have no effect on application execution.

Setting state

Avoid setting state inside your assertion decorators. If possible, they should effectively be pure functions. You may want to remove them at some point in the future, so you want them to have zero effect of your application logic.

If you must set state in a assertion decorator, isolate it from the rest of the application.

e.g. Create a window.decoratorState object where you can store any info you need to persist between calls to you assertion decorator, and do not access this object in your production code.

Automated Cleanup

If you want to remove your assertion decorators for performance reasons, you will want to...
  • Add a "remove assertion decorators" step to you production build process.
  • Implement your assertion decorators in a separate .ts file.
    • Remove this file from your production build
    • In this way, when you compile to production, the assertion decorators just won't be there, and you can be sure that your "remove assertion decorators" step did not miss anything.

Types of Assertions

  • app state
  • method arguments
    • e.g. testing string with a RegEx
  • context
    • e.g. the type of the this object
  • Function.caller (example)
    • Enforcing that a class A methods are only called from methods X or Y



No comments:

Post a Comment