Disenchanted

moon
sun

How to Design a Type Friendly Context

August 03, 2021

In the world of JavaScript, Koa is a milestone. Although sinatra is born before it, Koa makes it really popular that apps should built by a simple core to load plugins, and bundles of plugins to implement unique features. Today lots of apps are built with this pattern. For example, vscode and webpack.

Context in JavaScript

In the world of Koa, ctx is a magic box :crystal_ball:. Users can get all sorts of properties on it. For example, you can get ctx.session if you install the koa-session plugin. And you can get ctx.request.body if you install the koa-body plugin.

A typical Koa plugin (also known as middleware) will be like:

app.use(async (ctx, next) => {

    // inject props into ctx
    ctx.foo = 'bar';

    const startTime = Date.now();

    await next();

    // do something after other ctx done.
    const endTime = Date.now();
    const duration = endTime - startTime;

    console.log('Ctx duration:', duration);
})

Static Type Checking

Everything seems perfect until static type system join the game, which is bring in by TypeScript and Flow. With the safe type checking and powerful editor lsp features, people use them to build not only large systems, but also small apps and tools.

But when Koa meets static type checking, :boom: everything stop working. Type system cannot infer what property is really on ctx and what’s not. For example, if I call ctx.foo, how do I know wether the plugin inject the foo property is loaded in current Koa app or not? What’s more, users can’t get the hint of the editor because the type system don’t know what to suggest.

It’s a common problem of languages with static type system: how to handle object shared between modules elegantly?


Design

The key is using IoC. With this pattern we can inject type information into context.

Let’s reconsider the design of context in koa, we can see that the context is an object with properties you can modify, such as ctx.foo. What if we transform this API into ctx.get(foo)? Since the creation of foo is what we can control, we can write some information on it.

So, let’s assume the API of context is designed as this:

const ctx = createCtx();

const numberSlice = createSlice(0);

// inject a ctx.
ctx.inject(numberSlice);

const number = ctx.get(numberSlice); // -> 0

// set value of numberSlice to 1.
ctx.set(numberSlice, number + 1);

I introduced you a new data structure: slice. With this design, We just split up the entire ctx into several pieces of slices.

Now we can get define the structure of ctx and slice:

type Ctx = Map<symbol, Slice>;

type Slice<T = unknown> = {
    id: symbol;
    set: (value: T) => void;
    get: () => T;
}

Slice

Then, let’s try to implement the slice:

type Metadata<T> = {
    id: symbol;
    (ctx: Ctx): Slice<T>;
};

const createSlice = <T>(defaultValue: T): Metadata<T> => {
    const id = Symbol('Slice');

    const metadata = (ctx: Ctx) => {
        let inner = defaultValue;
        const slice: Slice<T> = {
            id,
            set: (next) => {
                inner = next;
            },
            get: () => inner
        }
        ctx.set(id, slice as Slice);
        return slice;
    }
    metadata.id = id;

    return metadata;
}

We create a metadata that brings slice’s information on it. And a slice factory that can be used to inject on context.

Ctx

The implementation of ctx will be much simpler:

const createCtx = () => {
    const map: Ctx = new Map();

    const getSlice = <T>(metadata: Metadata<T>): Slice<T> => {
        const value = map.get(metadata.id);
        if (!value) {
            throw new Error('Slice not injected');
        }
        return value as Slice<T>;
    }

    return {
        inject: <T>(metadata: Metadata<T>) => metadata(map),
        get: <T>(metadata: Metadata<T>): T => getSlice(metadata).get(),
        set: <T>(metadata: Metadata<T>, value: T): void => {
            getSlice(metadata).set(value);
        }
    }
}

We use a simple Map as the container of slices, with the symbol as key so the slices will not be conflict between each other.

Testing

Now our context has been done, let’s do some test:

const num = createSlice(0);
const ctx1 = createCtx();
const ctx2 = createCtx();

ctx1.inject(num);
ctx2.inject(num);

const x = ctx1.get(num); // editor will know x is number
ctx1.set(num, x + 1);

// this line will have an error since num slice only accept number
ctx.set(num, 'string')


ctx1.get(num); // => 1
ctx2.get(num); // => still 0

Now we have built a type friendly context using IoC, with slices that can be shared between context, but values will be isolated.


Full Code

type Ctx = Map<symbol, Slice>;

type Slice<T = unknown> = {
  id: symbol;
  set: (value: T) => void;
  get: () => T;
};

type Metadata<T> = {
  id: symbol;
  (ctx: Ctx): Slice<T>;
};

const createSlice = <T>(defaultValue: T): Metadata<T> => {
  const id = Symbol("Slice");

  const metadata = (ctx: Ctx) => {
    let inner = defaultValue;
    const slice: Slice<T> = {
      id,
      set: (next) => {
        inner = next;
      },
      get: () => inner
    };
    ctx.set(id, slice as Slice);
    return slice;
  };
  metadata.id = id;

  return metadata;
};

const createCtx = () => {
  const map: Ctx = new Map();

  const getSlice = <T>(metadata: Metadata<T>): Slice<T> => {
    const value = map.get(metadata.id);
    if (!value) {
      throw new Error("Slice not injected");
    }
    return value as Slice<T>;
  };

  return {
    inject: <T>(metadata: Metadata<T>) => metadata(map),
    get: <T>(metadata: Metadata<T>): T => getSlice(metadata).get(),
    set: <T>(metadata: Metadata<T>, value: T): void => {
      getSlice(metadata).set(value);
    }
  };
};

Testing:

const num = createSlice(0);
const ctx1 = createCtx();
const ctx2 = createCtx();

ctx1.inject(num);
ctx2.inject(num);

const values = [];

const x = ctx1.get(num);
values.push(x);

ctx1.set(num, x + 1);

values.push(ctx1.get(num));
values.push(ctx2.get(num));

expect(values).toEqual([0, 1, 0]);

Mirone

Personal blog by Mirone. Kiss the demons out of my dreams.