DEV Community

Cover image for TypeScript Wrapper: Optional Inputs and Dynamic Output Types
Nicolas Bouvrette
Nicolas Bouvrette

Posted on • Originally published at blog.avansai.com

TypeScript Wrapper: Optional Inputs and Dynamic Output Types

While many articles explain how to write a wrapper, in this one we’ll show you how to fully control its type. But first, let’s define what a wrapper is:

A wrapper is a function that encapsulates another function. Its primary purpose is to execute additional logic that the original function wouldn't perform, typically to help reduce boilerplate code.

When done correctly, you can add options to your wrapper that will dynamically determine its output types. This means that you can construct a fully customizable, type-safe wrapper that will enhance your development experience.

Before we start

All the examples from this article are also provided TypeScript Playground. Given that this article is centered around a practical example, I’ve had to mock certain functions and types to demonstrate how they work. The code provided below will be used in most of the examples, but will not be repeatedly included in each individual example, to make the content easier to read:

// These should be Next.js types, typically imported from next.
type NextApiRequest = { body: any }
type NextApiResponse = { json: (arg: unknown) => void }

// Under normal circumstances, this function would return a unique ID for a logged-in user.
const getSessionUserId = (): number | null => {
  return Math.random() || null
}

// This function is utilized to parse the request body and perform basic validation.
const parseNextApiRequestBody = <B = object>(
  request: NextApiRequest
): Partial<B> | null => {
  try {
    const parsedBody = JSON.parse(request.body as string) as unknown
    return typeof parsedBody === 'object' ? parsedBody : null
  } catch {
    return null
  }
}
Enter fullscreen mode Exit fullscreen mode

In addition to the main code, we’ll also be using two other tools. These won’t be included in the individual examples but will be available in the TypeScript Playground. The first is a type called Expand, which is based on the following code:

type Expand<T> = T extends ((...args: any[]) => any) | Date | RegExp
  ? T
  : T extends ReadonlyMap<infer K, infer V>
  ? Map<Expand<K>, Expand<V>>
  : T extends ReadonlySet<infer U>
  ? Set<Expand<U>>
  : T extends ReadonlyArray<unknown>
  ? `${bigint}` extends `${keyof T & any}`
    ? { [K in keyof T]: Expand<T[K]> }
    : Expand<T[number]>[]
  : T extends object
  ? { [K in keyof T]: Expand<T[K]> }
  : T
Enter fullscreen mode Exit fullscreen mode

This utility type was contributed by kelsny in a Stack Overflow comment. As discussed in the thread, it’s not production-ready and serves purely as a utility function for easily expanding types. Without Expand, we wouldn’t be able to view the individual type’s properties directly within the TypeScript Playground examples.

The second tool is the // ^? syntax, which many people may be unfamiliar with. This is a unique TypeScript Playground feature that dynamically displays the type of the variable to which the ^ is pointing (above), making it easier to keep track of your types as you modify the code. When used with Expand, it can be extremely useful for troubleshooting your types.

⚠ Normally, we would place all wrappers in a helper file (e.g., /src/api.helper.ts) so they could be reused across all APIs. However, for the sake of this article, we’ll put all the code in the same file to easily demonstrate it in action.

Our practical example

We recently encountered a TypeScript challenge while trying to improve our Next.js API wrapper. Because it provides a good example, and is both practical and easy to understand, we’ll use it throughout this article. Don’t worry, you don’t need to know anything about Next.js; that’s not the focus of this article.

For those unfamiliar with Next.js, here’s how you define an API: create a file in /pages/api/hello.tsx, copy and paste the code below, and you’ll have a neat {"hello": "world"} API.

import { NextApiRequest, NextApiResponse } from 'next'

export default async (
  request: NextApiRequest,
  response: NextApiResponse
): Promise<void> => {
  return void response.json({ hello: 'world' })
}
Enter fullscreen mode Exit fullscreen mode

This approach works perfectly fine for small applications. But what happens when your application grows and you start having numerous APIs that perform a lot of the same logic repeatedly? Typically, most developers write a wrapper on top to handle the repetitive logic.

The need for a wrapper

Let’s suppose, for instance, that we have some APIs that require authentication and others that don’t. We want a wrapper to handle this logic. Here’s one way we can accomplish this:

Complete example in TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
}

type CallbackOptions<O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
} & (O['requiresAuthentication'] extends true ? { userId: string } : object)

export const handleRequest =
  <O extends Options = Options>(
    options: O,
    callback: (options: CallbackOptions<O>) => Promise<void>
  ) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()
    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    return callback({
      request,
      response,
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<O>)
  }
Enter fullscreen mode Exit fullscreen mode

By introducing an options argument in the handler, we can specify which option we want to use, and the type of the callback will update dynamically. This feature is incredibly useful, as it prevents us from using an option that isn’t available in the callback.

For instance, if you utilize handleRequest with no options like this:

export default handleRequest({}, async (options) => {
  // some API code here...
})
Enter fullscreen mode Exit fullscreen mode

Now, options only contains request and response, which is more or less equivalent to having no wrapper. However, when you use it with an option, it becomes significantly more useful:

export default handleRequest(
  { requiresAuthentication: true },
  async (options) => {
    // some API code here...
  }
)
Enter fullscreen mode Exit fullscreen mode

options includes requestresponse, and userId. If the user is not logged in, the code inside the wrapper will not be executed.

This means that by setting different options, we can leverage TypeScript to identify any type issues with our code during development.

Taking it one step further

Let’s take this a step further. What if we wanted the wrapper to optionally parse the request’s body and return the correct type? We can accomplish this as follows:

Complete example in TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
}

type CallbackOptions<B = never, O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
  parsedRequestBody: B
} & (O['requiresAuthentication'] extends true ? { userId: string } : object)

export const handleRequest =
  <B = never, O extends Options = Options>(
    options: O,
    callback: (options: CallbackOptions<B, O>) => Promise<void>
  ) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()
    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    return callback({
      request,
      response,
      parsedRequestBody: {} as B,
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<B, O>)
  }
Enter fullscreen mode Exit fullscreen mode

By introducing a new generic B that we can pass to handleRequest, we can now specify the type of the payload that will be sent in the body when the API is called, and receive the corresponding type for it. For example:

export default handleRequest<{ hello: string }>({}, async (options) => {
  // some API code here...
})
Enter fullscreen mode Exit fullscreen mode

In this case, options includes requestresponse, and parsedRequestBody. The parsedRequestBody is of type { hello: string }. However, the challenge now is that we’ve only implemented the generic type; we haven’t included the logic that will check whether this option is present or not. We need to add a new option to accomplish this, as shown below:

Complete example in TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
  parseBody?: boolean
}

type CallbackOptions<B = never, O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
} & (O['requiresAuthentication'] extends true ? { userId: string } : object) &
  (O['parseBody'] extends true ? { parsedRequestBody: B } : object)

export const handleRequest =
  <B = never, O extends Options = Options>(
    options: O,
    callback: (options: CallbackOptions<B, O>) => Promise<void>
  ) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()
    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    // Check if the request's body is valid.
    const parsedRequestBody = options.parseBody
      ? parseNextApiRequestBody<O>(request)
      : undefined
    if (options.parseBody && !parsedRequestBody) {
      return void response.json({ error: 'invalid payload' })
    }

    return callback({
      request,
      response,
      ...(options.parseBody ? { parsedRequestBody } : {}),
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<B, O>)
  }
Enter fullscreen mode Exit fullscreen mode

When we add this new callback option (O['parseBody'] extends true ? { parsedRequestBody: B } : object), if the parseBody option is set to true, we should obtain a new option named parsedRequestBody, which carries the type of the generic B. This follows the exact same logic as what we did for requiresAuthentication. However, the only difference is that it utilizes a generic. We can try to implement it like this:

export default handleRequest<{ hello: string }>(
  { parseBody: true },
  async (options) => {
    // some API code here...
  }
)
Enter fullscreen mode Exit fullscreen mode

When inspecting options, we quickly find that parsedRequestBody is not available. But why? We are using exactly the same logic as the one used by requiresAuthentication, which still works when using the following code:

export default handleRequest(
  { requiresAuthentication: true },
  async (options) => {
    // some API code here...
  }
)
Enter fullscreen mode Exit fullscreen mode

What is going on?

When you partially specify generic parameters in TypeScript, the remaining parameters will fall back to their default values rather than being inferred from usage. This happens with the handleRequest function. When we provide a type for the B parameter (body type) but not for the O parameter (options type), TypeScript falls back to the default Options type for O.

In the Options type, parseBody and requiresAuthentication are optional properties, with their types defaulting to boolean | undefined. When these properties aren’t explicitly specified, TypeScript assigns them their default type, boolean | undefined, which is a union type.

In the CallbackOptions type, the parsedRequestBody and userId fields are conditionally included based on whether O['parseBody'] and O['requiresAuthentication'] extend true.

As a result, these fields are not included in the type. This behavior is triggered only when generic parameters are partially specified. It isn’t because the types could be undefined, but because the entire union type doesn’t extend true.

Therefore, with TypeScript’s current behavior, making generic inference work is basically an “all or nothing” approach.

There is an old (2016) GitHub issue discussing this specific topic here: https://github.com/microsoft/TypeScript/issues/10571

A maze made out of binary numbers with figures walking on top.

How to tackle the partial generic parameter inference limitation in TypeScript?

The two main workarounds that come to mind are typically the following:

  • Specifying all generic parameters: When using handleRequest, you could define all your types explicitly, as shown: handleRequest<{ hello: string }, { requiresAuthentication: true }>({ requiresAuthentication: true }, async (options) => {. While this may work, it forces you to specify all parameters, even the ones you’re not currently using. This can lead to a less than optimal developer experience, as the code may become harder to read and maintain with the growth in the number of options. For a demonstration, have a look at this TypeScript Playground.
  • Creating dedicated functions: Instead of having a one-size-fits-all handleRequest, you could create bespoke functions like handleRequestWithAuthAndBody and handleRequestWithAuth, etc. The drawback here is the potential for significant code duplication. As your options expand, maintaining the code could become a daunting task. For a hands-on look, check out this example on TypeScript Playground.

So, is there an optimal solution to this problem? It seems that every approach comes with its own set of challenges, which is a bit surprising considering this is a common hurdle in larger projects.

This is where a technique called “currying” comes into play (and incidentally, it’s the main reason we wrote this article, as this solution isn’t widely known). Kudos to @Ryan Braun for devising this approach as we encountered the problem.

A man eating red hot curry.

What is “currying”?

Currying is a technique in functional programming where a function with multiple arguments is transformed into a sequence of functions, each with a single argument. For instance, a function that takes three parameters, curriedFunction(x, y, z), becomes (x) => (y) => (z) => { /* function body */ }.

Using currying to fix the partial generic parameter inference limitation

Currying can be a solution to this problem. By splitting handleRequest into two parts, each accepting one argument, we allow TypeScript to infer the types in two stages. The first function takes the options argument, and return a function that takes the callback argument. This way, TypeScript has the necessary context to infer the correct type when you call the returned function.

Complete example in TypeScript Playground

type Options = {
  requiresAuthentication?: boolean
  parseBody?: boolean
}

type CallbackOptions<B = never, O extends Options = Options> = {
  request: NextApiRequest
  response: NextApiResponse
} & (O extends { requiresAuthentication: true } ? { userId: string } : object) &
  (O extends { parseBody: true } ? { parsedRequestBody: B } : object)

const handleRequest =
  <O extends Options>(options: O) =>
  <B = never>(callback: (options: CallbackOptions<B, O>) => Promise<void>) =>
  async (request: NextApiRequest, response: NextApiResponse) => {
    // If the user is not found, we can return a response right away.
    const userId = getSessionUserId()

    if (options.requiresAuthentication && !userId) {
      return void response.json({ error: 'missing authentication' })
    }

    // Check if the request's body is valid.
    const parsedRequestBody = options.parseBody
      ? parseNextApiRequestBody(request)
      : undefined

    if (options.parseBody && !parsedRequestBody) {
      return void response.json({ error: 'invalid payload' })
    }

    return callback({
      request,
      response,
      ...(options.parseBody ? { parsedRequestBody } : {}),
      ...(options.requiresAuthentication ? { userId } : {}),
    } as CallbackOptions<B, O>)
  }
Enter fullscreen mode Exit fullscreen mode

In this manner, we adhere to the “all or nothing” principle (or is it a limitation?) of TypeScript. We can use the same wrapper to parse a request body:

export default handleRequest({ parseBody: true })<{
  hello: string
}>(async (options) => {
  // some API code here...
})
Enter fullscreen mode Exit fullscreen mode

Or verify if a user is logged in:

export default handleRequest({ requiresAuthentication: true })(
  async (options) => {
    // some API code here...
  }
)
Enter fullscreen mode Exit fullscreen mode

The main drawback of this approach is that the syntax might seem a bit odd, particularly for those not familiar with currying. If you plan to use this, consider adding a comment to explain the rationale behind the implementation. This can prevent someone from wasting hours trying to refactor the wrapper.

Conclusion

TypeScript has emerged as a rising star in programming language surveys, yet a considerable number of developers still prefer to use standard JavaScript. TypeScript can be quite powerful, but it can also prove frustrating when it doesn’t behave as expected. It’s easy to complain about the limitations of TypeScript, but as we’ve seen, there are often creative ways to solve problems and maximize the benefits of TypeScript. At the end of the day, TypeScript can help prevent bugs during development and can be easier to use when multiple developers are working on the same project. Knowing these kinds of tricks can significantly enhance the experience of working with TypeScript.

Top comments (1)

Collapse
 
mdube profile image
Marc-Antoine Dubé

That's awesome!