ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

TypeScript 包装器:可选输入和动态输出类型

2023-07-16 18:23:20  阅读:126  来源: 互联网

标签:TypeScript Currying


包装器是封装另一个函数的函数。它的主要目的是执行原始函数不会执行的其他逻辑,通常是为了帮助减少样板代码。

正确完成后,您可以向包装器添加选项,这些选项将动态确定其输出类型。这意味着您可以构建一个完全可自定义的类型安全包装器,以增强您的开发体验。

在我们开始之前

本文中的所有示例也都提供了 TypeScript Playground。鉴于本文以一个实际示例为中心,我不得不模拟某些函数和类型来演示它们的工作原理。下面提供的代码将在大多数示例中使用,但不会重复包含在每个单独的示例中,以使内容更易于阅读:

// 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
  }
}

除了主代码,我们还将使用另外两个工具。这些不会包含在单个示例中,但将在 TypeScript Playground 中提供。第一个是名为 的类型,它基于以下代码:Expand

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

此实用程序类型由 kelsny 在堆栈溢出注释中贡献。如线程中所述,它不是生产就绪的,纯粹用作轻松扩展类型的实用程序函数。如果没有 ,我们将无法直接在 TypeScript Playground 示例中查看单个类型的属性。Expand

第二个工具是语法,许多人可能不熟悉。这是一个独特的 TypeScript Playground 功能,可动态显示 所指向的变量的类型(如上),从而在修改代码时更轻松地跟踪类型。与 一起使用时,它对于排除类型故障非常有用。// ^?^Expand

⚠ 通常,我们会将所有包装器放在一个辅助文件中(例如,),以便它们可以在所有 API 中重用。但是,为了本文的目的,我们将所有代码放在同一个文件中,以便在实际操作中轻松演示它。/src/api.helper.ts

我们的实际示例

我们最近在尝试改进Next.js API包装器时遇到了TypeScript挑战。因为它提供了一个很好的示例,并且既实用又易于理解,我们将在本文中使用它。别担心,你不需要知道任何关于Next.js的信息;这不是本文的重点。

对于那些不熟悉Next.js的人来说,以下是定义API的方法:在中创建文件,复制并粘贴下面的代码,您将拥有一个简洁的API。/pages/api/hello.tsx{"hello": "world"}

import { NextApiRequest, NextApiResponse } from 'next'

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

这种方法非常适合小型应用程序。但是,当您的应用程序增长并且您开始拥有大量重复执行许多相同逻辑的 API 时,会发生什么?通常,大多数开发人员在顶部编写一个包装器来处理重复的逻辑。

对包装器的需求

例如,假设我们有一些 API 需要身份验证,而另一些则不需要。我们想要一个包装器来处理这个逻辑。以下是我们实现此目的的一种方法:

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>)
  }

通过在处理程序中引入参数,我们可以指定要使用的选项,并且回调的类型将动态更新。此功能非常有用,因为它可以防止我们使用回调中不可用的选项。options

例如,如果您在没有以下选项的情况下使用:handleRequest

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

现在,只包含 和 ,这或多或少相当于没有包装器。但是,当您将其与选项一起使用时,它会变得更加有用:optionsrequestresponse

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

options包括 、 和 。如果用户未登录,则不会执行包装器中的代码。requestresponseuserId

这意味着通过设置不同的选项,我们可以利用 TypeScript 在开发过程中识别代码的任何类型的问题。

更进一步

让我们更进一步。如果我们希望包装器选择性地解析请求的正文并返回正确的类型,该怎么办?我们可以按如下方式完成此操作:

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>)
  }

通过引入我们可以传递给的新泛型,我们现在可以指定调用 API 时将在正文中发送的有效负载的类型,并为其接收相应的类型。例如:BhandleRequest

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

在本例中,包括 、 和 。的类型为 。但是,现在的挑战是我们只实现了泛型类型;我们尚未包含将检查此选项是否存在的逻辑。我们需要添加一个新选项来完成此操作,如下所示:optionsrequestresponseparsedRequestBodyparsedRequestBody{ hello: string }

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>)
  }

当我们添加这个新的回调选项时,如果该选项设置为 ,我们应该获得一个名为 的新选项,它带有泛型的类型。这遵循与我们所做的完全相同的逻辑。但是,唯一的区别是它使用泛型。我们可以尝试像这样实现它:(O['parseBody'] extends true ? { parsedRequestBody: B } : object)parseBodytrueparsedRequestBodyBrequiresAuthentication

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

在检查时,我们很快发现不可用。但是为什么?我们使用的逻辑与 使用的逻辑完全相同,在使用以下代码时仍然有效:optionsparsedRequestBodyrequiresAuthentication

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

这是怎么回事?

在 TypeScript 中部分指定泛型参数时,其余参数将回退到其默认值,而不是从使用情况推断出来。函数会发生这种情况。当我们为参数(正文类型)提供类型但不为参数(选项类型)提供类型时,TypeScript 会回退到 .handleRequestBOOptionsO

在类型中,和是可选属性,其类型默认为 。如果未显式指定这些属性,TypeScript 会为它们分配其默认类型,即联合类型。OptionsparseBodyrequiresAuthenticationboolean | undefinedboolean | undefined

在类型中,根据是否和扩展有条件地包含 和 字段。CallbackOptionsparsedRequestBodyuserIdO['parseBody']O['requiresAuthentication']true

因此,这些字段不包括在类型中。仅当部分指定泛型参数时,才会触发此行为。这不是因为类型可以是 ,而是因为整个联合类型没有扩展。undefinedtrue

因此,以 TypeScript 目前的行为,使泛型推理工作基本上是一种“全有或全无”的方法。

这里有一个旧的(2016 年)GitHub 问题讨论这个特定主题:https://github.com/microsoft/TypeScript/issues/10571

 

如何解决 TypeScript 中的部分泛型参数推理限制?

想到的两个主要解决方法通常如下:

  • 指定所有泛型参数:使用 时,可以显式定义所有类型,如下所示:。虽然这可能有效,但它会强制您指定所有参数,甚至是您当前未使用的参数。这可能会导致开发人员体验不佳,因为随着选项数量的增加,代码可能会变得难以阅读和维护。有关演示,请查看此 TypeScript Playground。handleRequesthandleRequest<{ hello: string }, { requiresAuthentication: true }>({ requiresAuthentication: true }, async (options) => {
  • 创建专用功能:您可以创建自定义功能,例如 and 等,而不是一刀切。这里的缺点是可能会出现大量代码重复。随着选项的扩展,维护代码可能成为一项艰巨的任务。要亲自动手查看,请在 TypeScript Playground 上查看此示例。handleRequesthandleRequestWithAuthAndBodyhandleRequestWithAuth

那么,这个问题有没有最佳解决方案呢?似乎每种方法都有自己的一系列挑战,考虑到这是大型项目中的常见障碍,这有点令人惊讶。

这就是一种称为“咖喱”的技术发挥作用的地方(顺便说一下,这是我们写这篇文章的主要原因,因为这个解决方案并不广为人知)。感谢@Ryan Braun在我们遇到问题时设计了这种方法。

什么是“咖喱”?

Currying 是函数式编程中的一种技术,其中具有多个参数的函数被转换为一系列函数,每个函数都有一个参数。例如,一个接受三个参数的函数 变为 。curriedFunction(x, y, z)(x) => (y) => (z) => { /* function body */ }

使用柯里化修复部分泛型参数推理限制

咖喱可以解决这个问题。通过分成两部分,每部分接受一个参数,我们允许 TypeScript 分两个阶段推断类型。第一个函数接受参数,并返回一个接受参数的函数。这样,TypeScript 就具有必要的上下文,可以在调用返回的函数时推断正确的类型。handleRequestoptionscallback

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>)
  }

通过这种方式,我们坚持 TypeScript 的“全有或全无”原则(或者它是一个限制?)。我们可以使用相同的包装器来解析请求正文:

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

或者验证用户是否已登录:

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

这种方法的主要缺点是语法可能看起来有点奇怪,特别是对于那些不熟悉柯里的人来说。如果您打算使用它,请考虑添加注释来解释实现背后的基本原理。这可以防止有人浪费时间尝试重构包装器。

标签:TypeScript,Currying
来源:

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有