typescriptdenooak

How to strongly type the Oak context state object in deno?


I want to strongly type the context.state object provided by oak in deno. I have already seen approaches how this could possibly work (e.g. Deno oak v10.5.1 context.cookies never being set), but have not yet managed to implement it myself in my own code.

My goal is to access the strongly typed context.state in each of my middlewares.

This is the interface for the context.state:

interface State {
    userID: number;
    sessionID: number;
}

This would be one of my middleware functions for setting the context.state in which I tried to access the context state properties:

const contextMiddleware = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID = 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

My problem is that in the contextMiddleware function these two properties are of type any and not the expected type of number. Furthermore intellisense does not recognise these to properties for autocompletion.

I have seen that the solution might be to pass IState interface as generics to the Application and Router object calling the middleware and then set the contextMiddlewate function to type RouterMiddleware or Middleware with the same generics from the oak import mod.ts.

This could look like this but it doesnt work at the moment:

import {
  Application,
  type Context,
  type Middleware,
  Router,
} from "https://deno.land/x/oak@v10.6.0/mod.ts";

interface State {
    userID: number;
    sessionID: number;
}

const contextMiddleware: Middleware<State, Context<State, State>> = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID= 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

const defaultRouter = new Router<State>();

defaultRouter
  .use(contextMiddleware)
  .get("/(.*)", (context: Context) => {
    context.response.status = 404;
    context.response.body = "Endpoint not available!";
  });

const app = new Application<State>();

app.use(defaultRouter.routes(), defaultRouter.allowedMethods());

app.addEventListener("listen", ({ hostname, port, secure }) => {
  console.log(
    `Listening on ${secure ? "https://" : "http://"}${
      hostname || "localhost"
    }:${port}`,
  );
});

await app.listen({ port: 8080 });

What am I missing here?

Thanks for your help in advance!


Solution

  • Because of how many of Oak's types use generics (often with default type parameters), and specifically the way that they use those types to create new types (in some cases, not allowing you to provide types for use in the slots which have defaults and using the default values instead), strongly typing Oak middleware can be extremely complicated — especially if your primary goal is strict type safety.

    Note that Oak is a convenience API for middleware-oriented web servers. Its primary purpose is to handle the boilerplate and help you focus on your application code, and — to be fair — it has made some amazing compromises which provide really good types in doing so. However, the defaults used in Oak's type system appear to be biased toward convenience over safety in some places in order to do that, so it's important to keep that in mind.

    Generics can sometimes feel trying, and a potentially tricky part about this is that there's not just one "context" type in Oak (it depends on where the context is being used), and — non-trivially — there's not just one "state" type (Oak allows you to potentially create unique state data for each request/response cycle, offering you options for the strategy it uses to initialize it — more on this below).

    Let's start from the bottom of the dependent types involved and work our way up to get a complete understanding. Starting with the app:

    The type of Application looks like this:

    class Application<AS extends State = Record<string, any>> extends EventTarget {
      constructor(options?: ApplicationOptions<AS, ServerRequest>);
      // --- snip ---
      state: AS;
      // --- snip ---
      use<S extends State = AS>(
        middleware: Middleware<S, Context<S, AS>>,
        ...middlewares: Middleware<S, Context<S, AS>>[]
      ): Application<S extends AS ? S : (S & AS)>;
      use<S extends State = AS>(
        ...middleware: Middleware<S, Context<S, AS>>[]
      ): Application<S extends AS ? S : (S & AS)>;
      // --- snip ---
    }
    

    The AS type parameter has a constraint of a type alias State which looks like this:

    type State = Record<string | number | symbol, any>;
    

    (which is basically just "any plain object"). Now let's look at the options used to create the app: ApplicationOptions, which looks like this:

    interface ApplicationOptions <S, R extends ServerRequest> {
      contextState?:
        | "clone"
        | "prototype"
        | "alias"
        | "empty";
      keys?: KeyStack | Key[];
      logErrors?: boolean;
      proxy?: boolean;
      serverConstructor?: ServerConstructor<R>;
      state?: S;
    }
    

    I'm going to skip showing the irrelevant types: ServerConstructor and KeyStack (which Oak's main module doesn't even export, so you'd have to go track down the type yourself (bad!)).

    The option for defining the strategy of how the context state is created is ApplicationOptions#contextState, and the inline documentation says this about it (also described using slightly different language here):

    Determine how when creating a new context, the state from the application should be applied. A value of "clone" will set the state as a clone of the app state. Any non-cloneable or non-enumerable properties will not be copied. A value of "prototype" means that the application's state will be used as the prototype of the the context's state, meaning shallow properties on the context's state will not be reflected in the application's state. A value of "alias" means that application's .state and the context's .state will be a reference to the same object. A value of "empty" will initialize the context's .state with an empty object.

    The default value is "clone".

    We're going to leave this undefined (which uses the default) when instantiating the app. Discussion of these algorithms is out of scope of the question, but it's important that you are aware of how this impacts the behavior of your code because each setting results in a different state object for the context. To review the source for how this value is created, start here.

    Because you don't have any application-level state, creating the app (with strict type safety) looks like this:

    // This means "an object with no property names and no values" (e.g. `{}`)
    type EmptyObject = Record<never, never>;
    type AppState = EmptyObject;
    
    const app = new Application<AppState>({ state: {} });
    

    Note, you can also instantiate it like this:

    const app = new Application<AppState>();
    

    and Oak will create the empty object for you, but I prefer for my code to be explicit.

    If you refer above to the default type that Oak uses for the generic type parameter AS (in the case that you don't provide one), you'll see that it's Record<string, any>. This choice is not very type safe, but is there to make using unknown or dynamic state data more convenient. Type safety and convenience are not always at odds with each other, but that's often the case.

    The above is important for everything following: it means that in every router and middleware, the type AppState should be used in place of the application state generic type parameter (for which Oak uses the parameter name AS): otherwise, Oak supplies the less-safe default.

    Now let's look at the state type which you plan to use in each of your request-response cycles (which Oak calls the context):

    type ContextState = {
      sessionID: number;
      userID: number;
    };
    

    You might notice that I've renamed the state type in your example. You are, of course, welcome to name your program variables and types however you please, but I want to encourage you not to prefix every type with I or T. You might have seen this in other people's code, but IMO it's just noise: it's like prefixing every variable in your program with v (e.g. vDate, vName, vAmount, etc.): there's just no need for that. Instead, I encourage you to follow the official TypeScript convention of using meaningful names in PascalCase.

    Next, let's look at Oak's Context. It looks like this:

    class Context<S extends AS = State, AS extends State = Record<string, any>> {
      constructor(
        app: Application<AS>,
        serverRequest: ServerRequest,
        state: S,
        secure?,
      );
      // --- snip ---
      app: Application<AS>; // { state: AS }
      // --- snip ---
      state: S;
      // --- snip ---
    }
    

    As you can see, the context state type and application state type are not necessarily the same.

    and, unless you dictate otherwise, (perhaps unsafely) Oak sets them that way (or even less safe, as Record<string, any>).

    Let's also look at the other context type that Oak provides: RouterContext. This context type is used within routers and is a narrower type which contains additional information regarding the route information: path, parameters, etc. It looks like this:

    interface RouterContext <
      R extends string,
      P extends RouteParams<R> = RouteParams<R>,
      S extends State = Record<string, any>,
    > extends Context<S> {
      captures: string[];
      matched?: Layer<R, P, S>[];
      params: P;
      routeName?: string;
      router: Router;
      routerPath?: string;
    }
    

    I won't discuss the RouteParams type here: it is a somewhat complex, recursive type utility which attempts to build a type-safe route parameters object from the route string literal parameter R.

    As you can see above, this type extends the Context type (extends Context<S>), but it only provides the context state type (S) to it, leaving the application state type (AS) undefined, which results in the default type being used (Record<string, any>). This is the first example seen here where Oak chooses not to give you a way to create type-safe code. However, we can make our own version which does.

    ⚠️ Important: The Layer class is not exported from the module where it is defined in Oak, even though it is used in Oak's public-facing (exported) types (again, very bad!). This makes it impossible to use in our custom stronger context type (unless we manually re-create the type), so we'll have to do that (what a headache!).

    import {
      type Context,
      type RouteParams,
      Router,
      type RouterContext,
      type State as AnyOakState,
    } from "https://deno.land/x/oak@v10.6.0/mod.ts";
    
    type EmptyObject = Record<never, never>;
    
    interface RouterContextStrongerState<
      R extends string,
      AS extends AnyOakState = EmptyObject,
      S extends AS = AS,
      P extends RouteParams<R> = RouteParams<R>,
    > extends Context<S, AS> {
      captures: string[];
      matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
      params: P;
      routeName?: string;
      router: Router;
      routerPath?: string;
    }
    

    This custom type will be important in creating type-safe router middleware functions because the utilities provided by Oak for creating them (Middleware and RouterMiddleware) suffer the same default-type-parameter-usage problems as the RouterContext type, and they look like this:

    interface Middleware<
      S extends State = Record<string, any>,
      T extends Context = Context<S>,
    > {
      (context: T, next: () => Promise<unknown>): Promise<unknown> | unknown;
    }
    
    interface RouterMiddleware<
      R extends string,
      P extends RouteParams<R> = RouteParams<R>,
      S extends State = Record<string, any>,
    > {
      (
        context: RouterContext<R, P, S>,
        next: () => Promise<unknown>,
      ): Promise<unknown> | unknown;
      param?: keyof P;
      router?: Router<any>;
    }
    

    This answer has already covered a lot (and we're not done yet!), so it's a good time to recap what we have so far:

    import {
      Application,
      type Context,
      type RouteParams,
      Router,
      type RouterContext,
      type State as AnyOakState,
    } from "https://deno.land/x/oak@v10.6.0/mod.ts";
    
    type EmptyObject = Record<never, never>;
    type AppState = EmptyObject;
    
    const app = new Application<AppState>({ state: {} });
    
    type ContextState = {
      userID: number;
      sessionID: number;
    };
    
    interface RouterContextStrongerState<
      R extends string,
      AS extends AnyOakState = EmptyObject,
      S extends AS = AS,
      P extends RouteParams<R> = RouteParams<R>,
    > extends Context<S, AS> {
      captures: string[];
      matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
      params: P;
      routeName?: string;
      router: Router;
      routerPath?: string;
    }
    

    With all of that covered, we are finally able to begin to cover the answer to your direct question which was "How to strongly type the Oak context state object in Deno?"

    Now, the real answer is (and you might find this frustrating): "it depends on what happens in your middleware functions, and the order in which they are invoked (chained together)". This is because each middleware can mutate the state and that new state will be what the next middleware receives. That's what makes this so complicated and likely why Oak chose to use Record<string, any> by default.

    So, let's look at what's happening in your example and create types to represent that. You have two middleware functions, and they are both on one router.

    The first function:

    A strongly-typed version of it would look like this:

    We also create a type alias NextFn for the next function so that we don't have to keep typing the entire function signature for other middleware.

    type NextFn = () => Promise<unknown>;
    
    async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
      context: Context<Partial<ContextState>, AppState>,
      next: NextFn,
    ): Promise<void> {
      context.state.userID = 123;
      //            ^? number | undefined
      context.state.sessionID = 456;
      //            ^? number | undefined
      await next(); // Wait for subsequent middleware to finish
      delete context.state.userID;
      delete context.state.sessionID;
    }
    

    Note that I use the type utility Partial<Type> on the ContextState (which sets all its members as optional). This is for two reasons:

    1. Those properties don't exist on the context state at the start of the function (the state is just an empty object at that point)

    2. They are deleted from the context state at the end of the function — and if you try to delete a non-optional property, you'll get this TS diagnostic error: The operand of a 'delete' operator must be optional. deno-ts(2790)

    The other function is in the same middleware chain after the first function, but only matches on GET requests and on routes which match /(.*).

    Aside: I'm not sure what your intention is for defining the route that way (if you just want the router to match on GET requests, then you can configure that using the router instantiation option RouterOptions#methods). In any case, you might find it useful to know that Oak's documentation states that it uses the path-to-regexp library for parsing these route strings.

    That one might look like this:

    function setEndpointNotFound(
      context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
    ): void {
      context.response.status = 404;
      context.response.body = "Endpoint not available!";
    }
    

    If you want to create a middleware function where you expect the previous one to have set the context state properties, you can use a type guard predicate function:

    function idsAreSet<T extends { state: Partial<ContextState> }>(
      contextWithPartialState: T,
    ): contextWithPartialState is T & {
      state: T["state"] & Required<Pick<T["state"], "sessionID" | "userID">>;
    } {
      return (
        typeof contextWithPartialState.state.sessionID === "number" &&
        typeof contextWithPartialState.state.userID === "number"
      );
    }
    
    async function someOtherMiddleware(
      context: Context<Partial<ContextState>, AppState>,
      next: NextFn,
    ): Promise<void> {
      // In the main scope of the function:
      context.state.userID;
      //            ^? number | undefined
      context.state.sessionID;
      //            ^? number | undefined
    
      if (idsAreSet(context)) {
        // After the type guard is used, in the `true` path scope:
        context.state.userID;
        //            ^? number
        context.state.sessionID;
        //            ^? number
      } else {
        // After the type guard is used, in the `false` path scope:
        context.state.userID;
        //            ^? number | undefined
        context.state.sessionID;
        //            ^? number | undefined
      }
    
      await next();
    }
    

    It's time to create the router and use our middleware; it's very straightforward:

    const router = new Router<Partial<ContextState>>();
    router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
    router.get("/(.*)", setEndpointNotFound);
    

    And using the router in the app is just as simple (straight from the code in your question):

    app.use(router.routes(), router.allowedMethods());
    

    Finally, let's wrap up by starting the server.

    The code in your question includes an application listen event callback function which logs the server address to the console. My guess is that you copied it from the example in the documentation. It's a nice function to have if you want to see a message in your console's stdout when your server starts. Here's a slight variation which uses the URL constructor so that if you happen to listen on the default port for the protocol you're using (e.g. 80 on http, 443 on https), that bit will be omitted from the address in the message (just like in the address that your browser shows you). It also happens to be compatible with the onListen callback used by the serve function from Deno's std library:

    function printStartupMessage({ hostname, port, secure }: {
      hostname: string;
      port: number;
      secure?: boolean;
    }): void {
      if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
      const address =
        new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
      console.log(`Listening at ${address}`);
      console.log("Use ctrl+c to stop");
    }
    
    app.addEventListener("listen", printStartupMessage);
    

    And... start the server:

    await app.listen({ port: 8080 });
    

    Here's the full module result from what was discussed above:

    import {
      Application,
      type Context,
      type RouteParams,
      Router,
      type RouterContext,
      type State as AnyOakState,
    } from "https://deno.land/x/oak@v10.6.0/mod.ts";
    
    type EmptyObject = Record<never, never>;
    type AppState = EmptyObject;
    
    const app = new Application<AppState>({ state: {} });
    
    type ContextState = {
      userID: number;
      sessionID: number;
    };
    
    interface RouterContextStrongerState<
      R extends string,
      AS extends AnyOakState = EmptyObject,
      S extends AS = AS,
      P extends RouteParams<R> = RouteParams<R>,
    > extends Context<S, AS> {
      captures: string[];
      matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
      params: P;
      routeName?: string;
      router: Router;
      routerPath?: string;
    }
    
    type NextFn = () => Promise<unknown>;
    
    async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
      context: Context<Partial<ContextState>, AppState>,
      next: NextFn,
    ): Promise<void> {
      context.state.userID = 123;
      context.state.sessionID = 456;
      await next();
      delete context.state.userID;
      delete context.state.sessionID;
    }
    
    function setEndpointNotFound(
      context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
    ): void {
      context.response.status = 404;
      context.response.body = "Endpoint not available!";
    }
    
    const router = new Router<Partial<ContextState>>();
    router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
    router.get("/(.*)", setEndpointNotFound);
    
    app.use(router.routes(), router.allowedMethods());
    
    // This is not necessary, but is potentially helpful to see in the console
    function printStartupMessage({ hostname, port, secure }: {
      hostname: string;
      port: number;
      secure?: boolean;
    }): void {
      if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
      const address =
        new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
      console.log(`Listening at ${address}`);
      console.log("Use ctrl+c to stop");
    }
    
    app.addEventListener("listen", printStartupMessage);
    
    await app.listen({ port: 8080 });
    
    

    It's quite a bit of information, but a lot is happening inside a web server framework, and doing it in a type-safe way is even more complicated!