RSLABBERT.COM
Typescript proxy magic

2021-05-13

As part of my work I published a blog post on how we are able to build a Typescript-based abstraction layer on top of the Azure Data Factory (ADF) API. Given that the "Typescript magic" code was my personal contribution, I wanted to do a quick personal post to show how a specific part of it works.

The relevant piece is near the end where we are able to replace a dynamic language run by ADF (normally written as plain text) with Typescript code that "compiles" to the correct text while providing type safety:

// This string is how we would normally write a dynamic property:
`@concat(pipeline().paramaters.Folder, "/", pipeline().paramaters.File)`;

// This is the updated Typescript method:
interface MyPipelineParams {
    Folder: { type: "String" };
    File: { type: "String" };
}
expression(
    pipeline<MyPipelineParams>().parameters.Folder,
    rawString("/"),
    pipeline<MyPipelineParams>().parameters.File
);

The specific part I wanted to dig into is the pipeline<T>() function. This is the code we used:

export const pipeline = <T>(): PipelineFunction<T> =>
    new PipelineFunction(
        createParamProxy("parameters", "pipeline()") as unknown as T
    );

export const createParamProxy = (
    p: string | number | symbol,
    parent: string
): ADFExpression =>
    new Proxy(new ADFExpression(p.toString()), {
        get: function (_target, prop) {
            const start = parent.length === 0 ? "" : parent + ".";

            if (prop === "toJSON" || prop === "toString") {
                return () => start + p.toString();
            }

            return createParamProxy(prop, start + p.toString());
        },
    });

The createParamProxy function is the core part of this. Basically, it (ab)uses the JS Proxy API to create a dynamic object that does the following:

  • When you access a property on the paramProxy, it will return a new paramProxy with the name of the parent path added to the new property name.
  • Unless you call toJSON or toString, in which case it will dump the entire path out as a string.

The clearest examples of this are the test cases for the createParamProxy function:

test("createParamProxy with parent toString()", () => {
    const proxy = createParamProxy("mock", "mockparent");

    expect(proxy.toString()).toEqual("mockparent.mock");
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    expect((proxy as any).toJSON()).toEqual("mockparent.mock");
});

test("createParamProxy without parent toString()", () => {
    const proxy = createParamProxy("mock", "");

    expect(proxy.toString()).toEqual("mock");
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    expect((proxy as any).toJSON()).toEqual("mock");
});

test("createParamProxy nesting", () => {
    interface MockType {
        Nest1: {
            Nest2: StringExpression;
        };
    }

    const proxy = createParamProxy("mock", "mockparent") as unknown as MockType;

    expect(proxy.Nest1.Nest2.toString()).toEqual("mockparent.mock.Nest1.Nest2");
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    expect((proxy.Nest1.Nest2 as any).toJSON()).toEqual(
        "mockparent.mock.Nest1.Nest2"
    );
});

And for an example of it in action with the pipeline function, we can use this test case:

test("pipeline syntax with other functions", () => {
    interface Test {
        Nest1: StringExpression;
    }

    expect(concat(pipeline<Test>().parameters.Nest1).toString()).toEqual(
        "concat(pipeline().parameters.Nest1)"
    );
});

All in all, this was a pretty cool example of how Typescript and Javascript operate at two different layers and that you can deliberately decouple those layers to do interesting things. Stay tuned for another post where I'll dig into how we used Typescript types to ensure functions like concat (which "compile" to the string "concat()") can be made type safe to ensure it only accepts other strings as inputs (i.e. concat("a string", 2) fails to compile as Typescript).