Skip to content

Instantly share code, notes, and snippets.

@laser
Last active May 12, 2020 09:54
Show Gist options
  • Save laser/d0228b95a2eb451dffb27e64d223d628 to your computer and use it in GitHub Desktop.
Save laser/d0228b95a2eb451dffb27e64d223d628 to your computer and use it in GitHub Desktop.
An ADT-Based Alternative to Dependency Injection in TypeScript

Preface

Like unit testing but dislike mocks and stubs? Want to keep your business logic separate from your side effects? In this blog post, I'll demonstrate a novel approach to testing which relies not on the Dependency Injection pattern, but an algebraic data structure and some simple functions.

A Real World Example

I was recently working on a system for a real estate company which merged a PDF-template with some data related a house that a real estate agent was trying to sell, and then rendered the output to the screen.

Each template contained a bunch of labeled boxes ("elements") - each associated with a symbol which indicated the nature of their content (e.g. house_1.photo_1 or agent_1.headshot_1).

The software we wrote was responsible for ensuring that each element rendered with a user-provided image and that it was labeled appropriately.

An array of potential element labels was built up by applying the following set of rules:

  1. If the element had metadata that indicated that it was uneditable by the user, don't label the box
  2. If the element had a symbol which mapped directly to a hard-coded label, use it
  3. Otherwise, we'd resolve the box's symbol to a label via the i18n system

Inverting Control, Dependency Injection Style

Our first attempt at building our label resolver took on a form familiar to most object-oriented programmers:

///////////////////////////////////////////////////////////////////////////////
// Shared Code
//////////////

interface PDF {
    // resolve an image URL to an image and render it into the PDF with the
    // provided label
    render(element: TemplateElement, label: string, imgUrl: string):void
}

interface TemplateElement {
    symbol: string;
    isEditable: boolean;
}

interface I18nService {
    // look up a translated string by locale + key
    lookup(loc: Locale, key: string): string;
}

interface ImageUrlResolver {
    // resolve the URL of some user-provided image for the element
    resolve(userId: string, el: TemplateElement): string;
}

enum Locale {
    EN_US,
    EN_CA,
    FR_CA,
    // etc.
}

///////////////////////////////////////////////////////////////////////////////
// Example 1A: Dependency Injection
//////////////////////////////////


interface LabelResolver {
    resolve(loc: Locale, el: TemplateElement): Array<string>;
}

class TemplateElementRenderer {

    public labelResolver: LabelResolver;
    public imageResolver: ImageUrlResolver;

    constructor(i18nSvc: I18nService, imgResolver: ImageUrlResolver) {
        this.labelResolver = new LabelResolver(i18nSvc);
        this.imageResolver = imgResolver;
    }

    public renderElement(pdf: IPDF, loc: Locale, userId: string, el: TemplateElement): void {
        pdf.render(
            el,
            this.labelResolver.resolve(loc, el),
            this.imageResolver.resolve(userId, el))
    }

}

class LabelResolverImpl implements LabelResolver {

    static HARD_CODED_MAPPINGS: {[key: string]: string} = {
        "house_1.photo_1": "Front of House",
        "agent_1.headshot_1": "Agent 1 Headshot",
        // etc.
    };

    public i18Service: I18nService;

    constructor(i18nSvc: I18nService) {
        this.i18Service = i18nSvc;
    }

    public resolve(loc: Locale, el: TemplateElement): string {
        if (!el..isEditable) {
            return "";
        }

        if (LabelResolver.HARD_CODED_MAPPINGS[el..symbol]) {
            return LabelResolver.HARD_CODED_MAPPINGS[el..symbol];
        }

        const i18nLookupResult = this.i18Service.lookup(loc, el..symbol);

        if (i18nLookupResult) {
            return i18nLookupResult;
        }

        return "";
    }
}

We instantiate a TemplateElementRenderer with some collaborators that allow us to interact with the outside world: I18nService and ImageUrlResolver and then we pass the I18nService to constructor of the nested LabelResolver, which we cache. Then, later, some program calls TemplateElementRenderer#renderElement, which delegates to its resolvers and affects a change to the PDF.

Now, for the tests:

///////////////////////////////////////////////////////////////////////////////
// Example 1B: Testing w/TS-Mockito
//////////////////////////////////

describe("label resolution", function() {

    it("should delegate to the i18n service", function() {
        const i18nService: I18nService = mock(TheRealI18nService);
        const resolver: LabelResolver = new LabelResolverImpl(instance(i18nService));

        when(i18nService.lookup(Locale.EN_CA, "house_1.door_4"))
            .thenReturn("Fourth House Door");

        const el: TemplateElement = {
            getSymbol: function() {
                return "house_1.door_4"
            },
            isEditable: function() {
                return true
            }
        };

        const actual = resolver.resolve(Locale.EN_CA, el);
        const expected = ["Fourth House Door"];

        expect(actual).to.equal(expected);
    });
});

Before testing the logic of our LabelResolver#resolve method, we set up some I18nService state using ts-mockito stubs. We call our resolve method which delegates to some stubs, ultimately resulting in a return value that we expect.

Complaints

The original implementation left a few things to be desired:

  1. We had business logic colocated with side effects (interactions with the i18n system)
  2. The I18nService reference needed to be plumbed through several classes
  3. Our unit test was fairly high ceremony (i.e. required a bit of setup)

What I really wanted was for my LabelResolver#resolve method to return a different type depending on the nature of the values that it resolved, and the enclosing class (TemplateElementRenderer) will be responsible for interpreting each value as it sees fit. To achieve this, I used something called a discriminated union, or as it's also called in the TypeScript documentation, an algebraic data type (ADT).

Discriminated Unions / Algebraic Data Types

So what's an ADT - and how do we create them? According to the TypeScript docs, the programmer needs three things in order to create an ADT:

  1. Types that have a common, singleton type property — the discriminant.
  2. A type alias that takes the union of those types — the union.
  3. Type guards on the common property.

Following those guidelines, we create an ADT to be returned by functions which can either succeed or fail:

///////////////////////////////////////////////////////////////////////////////
// Example 2: TypeScript ADTs
/////////////////////////////

interface Failure<E> {
    kind: "failure";
    reason: E;
}

interface Success<T> {
    kind: "success";
    value: T;
}

type Result<T, E> = Failure<E> | Success<T>;

In the above example, the Failure and Success interfaces both have a common, singleton type property - kind. These interfaces are unioned together to form our ADT, Result. For any function accepting a Result to be well typed it must first examine the value of the Result's kind before making use of Failure or Success-specific properties.

Some examples:

// fails with a TS2339 compiler error
function foo(x: Result<String, Error>): void {
    console.log(x.value);
}

// type checks because of the "type guard on the common property"
function bar(r: Result<String, Error>): void {
    switch (r.kind) {
        case "success": return console.log(r.value);
        case "failure": return console.log(r.reason);
    }
}

// fails with a TS2322 error: Object literal may only specify known properties,
// and '"value"' does not exist in type 'Failure<String>'
const bad1: Result<String, String> = {
    "kind": "failure",
    "value": "wat",
    "reason": "foo"
};

Using a technique similar to the one demonstrated in Rúnar Bjarnason's excellent blog post Structural Pattern Matching in Java, we can hide the type guard in a polymorphic matching-function, like result (below):

// hide the type guard
function result<T, U, E>(
    r: Result<T, E>,
    onSuccess: (f1: Success<T>) => U,
    onFailure: (f2: Failure<E>) => U
): U {
    switch (r.kind) {
        case "success": return onSuccess(r);
        case "failure": return onFailure(r);
    }
}

// example usage
console.log(result(
    doSomethingThatCouldFail(),
    function(s) {
        return "success: " + s.value.toString();
    }, function(e) {
        return "failure!"
    }));

Also of note is that TypeScript will prevent us from adding new types to our union without expanding our switch statement.

Back in the Real World

We create three new types to represent the nature of the strings that could be returned from our resolve method and union them together to create an ADT. Now, our resolve method can return a pure data structure and its caller can sort out how to interpret them into calls into the i18n service:

///////////////////////////////////////////////////////////////////////////////
// Example 3A: Using an ADT in Our App
//////////////////////////////////////

// no need to go to the i18n system
interface ResolvedLabel {
    kind: "resolved";
    value: string;
}

// look up a string in the i18n system
interface UnresolvedLabel {
    kind: "unresolved";
    element: TemplateElement;
}

// we shouldn't label this element
interface ResolvedNoLabel {
    kind: "nolabel";
}

type LabelResolutionResult = ResolvedLabel | ResolvedNoLabel | UnresolvedLabel;

function labelResolutionResult(
    r: LabelResolutionResult,
    onResolvedLabel: (f1: ResolvedLabel) => T,
    onResolvedNoLabel: (f2: ResolvedNoLabel) => T,
    onUnresolvedlabel: (f3: UnresolvedLabel) => T
): T {
    switch (r.kind) {
        case "resolved": return onResolvedLabel(r);
        case "nolabel": return onResolvedNoLabel(r);
        case "unresolved": return onUnresolvedlabel(r);
    }
}

class TemplateElementRenderer {

    public i18nService: I18nService;
    public imageResolver: ImageUrlResolver;

    constructor(i18nSvc: I18nService, imgResolver: ImageUrlResolver) {
        this.i18nService = i18nSvc;
        this.imageResolver = imgResolver;
    }

    public renderElement(pdf: PDF, loc: Locale, userId: string, el: TemplateElement): void {

        // interact with the i18n service if need be
        const label: string = labelResolutionResult(
            LabelResolver2.resolve(el),
            (resolved) => resolved.value,
            () => "",
            (unresolved) => this.i18nService.lookup(loc, unresolved.element..symbol)
        );

        pdf.render(el, label, this.imageResolver.resolve(userId, el));
    }

}

class LabelResolver {

    static HARD_CODED_MAPPINGS: {[key: string]: string} = {
        "house_1.photo_1": "Front of House",
        "agent_1.headshot_1": "Agent 1 Headshot",
        // etc.
    };

    public static resolve(el: TemplateElement): LabelResolutionResult {
        if (!el..isEditable) {
            return { "kind": "nolabel" };
        }

        if (LabelResolver.HARD_CODED_MAPPINGS[el..symbol]) {
            return {
                "kind": "resolved",
                "value": LabelResolver.HARD_CODED_MAPPINGS[el..symbol]
            };
        }

        return {
            "kind": "unresolved",
            "element": el
        };
    }
}

And finally, our tests can be written in a much more-straightforward manner:

///////////////////////////////////////////////////////////////////////////////
// Example 3B: Testing with an ADT
//////////////////////////////////

describe("label resolution", function() {
    it("should return a match for a hard-coded mapping, if exists", function() {
        const el: TemplateElement = {
            "symbol": "house_1.photo_1",
            "isEditable": true
        };

        const expected = "Front of House";
        const result = LabelResolver.resolve(Locale.EN_US, el);

        const actual = labelResolutionResult(
            result,
            (resolved) => resolved.value,
            () => throw new Error("unexpected"),
            (unresolved) => throw new Error("unexpected"))

        expect(actual).to.equal(expected);
    });

    it("should return an unresolved value", function() {
        const el: TemplateElement = {
            "symbol": "house_1.photo_1",
            "isEditable": true
        };

        const expected = "foo";
        const result = LabelResolver.resolve(Locale.EN_US, el);

        const actual = labelResolutionResult(
            result,
            (resolved) => throw new Error("unexpected"),
            () => throw new Error("unexpected"),
            (unresolved) => "foo")

        expect(actual).to.equal(expected);
    });
});

Given that our new resolve method doesn't need a reference to the i18n service, our tests can avoid stubs and instead operate on pure data.

Conclusion

I hope I've amused you with an ADT-based approach to inversion of control. I think that this approach aids us in:

  1. Separating pure business logic from impure, side-effectful code
  2. Decreasing the number of classes that need to know about the collaborators of nested classes
  3. Decreasing the amount of test setup code that we'd otherwise need to write

Like it? Hate it? Leave your thoughts in the comments section, below!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment