While many articles explain how to write a wrapper, we’ll show you how to control its type fully in this one. 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 to dynamically determine its output types. This means you can construct a fully customizable, type-safe wrapper to enhance your development experience.
Before we Start
All the examples are also provided by 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 improving our Next.js API wrapper. We’ll use it throughout this article because it provides a good example and is both practical and easy to understand. 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 repeatedly perform much of the same logic? 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 callback type will update dynamically. This feature is incredibly useful, as it prevents us from using an option unavailable 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 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 payload 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 to check whether this option is present. We need to add a new option to accomplish this, as shown below:
type Options = {
requiresAuthentication?: boolean
parseBody?: boolean
}
Complete example in TypeScript Playground.
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...
}
)
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 inspecting options, we quickly find that parsedRequestBody is not available. But why? We are using 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 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? Every approach comes with its own challenges, which is 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 returns 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>)
}t
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 seems odd, particularly for those unfamiliar 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 many developers still prefer 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.
TypeScript can help prevent bugs during development and be easier to use when multiple developers work on the same project. Knowing these kinds of tricks can significantly enhance the experience of working with TypeScript.
TypeScript Wrapper: Optional Inputs and Dynamic Output Types was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.