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
}
}
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
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' })
}
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>)
}
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...
})
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...
}
)
options
includes request
, response
, 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>)
}
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...
})
In this case, options
includes request
, response
, 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>)
}
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...
}
)
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...
}
)
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
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 likehandleRequestWithAuthAndBody
andhandleRequestWithAuth
, 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.
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>)
}
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...
})
Or verify if a user is logged in:
export default handleRequest({ requiresAuthentication: true })(
async (options) => {
// some API code here...
}
)
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)
That's awesome!