进阶 TypeScript:重新发明 lodash.get

2025-05-28

进阶 TypeScript:重新发明 lodash.get

作为金融机构后台团队的一员,我必须处理大量复杂的数据结构:客户个人数据、交易记录等等。有时,你需要呈现数据对象深处的值。为了简化流程,我可以使用
lodash.get ,它允许我通过路径访问值,从而避免无休止的obj.foo && obj.foo.bar条件判断(尽管在可选链式调用之后,这种情况已经不再存在了)。

这种方法有什么问题?

虽然_.get在运行时运行良好,但与 TypeScript 一起使用时有一个巨大的缺点:在大多数情况下,它无法推断值类型,这可能会在重构期间导致各种问题。

假设服务器向我们发送了存储客户地址的数据



type Address = {
  postCode: string
  street: [string, string | undefined]
}

type UserInfo = {
  address: Address
  previousAddress?: Address
}

const data: UserInfo = {
  address: {
    postCode: "SW1P 3PA",
    street: ["20 Deans Yd", undefined]
  }
}


Enter fullscreen mode Exit fullscreen mode

现在我们要渲染它



import { get } from 'lodash'

type Props = {
  user: UserInfo
}
export const Address = ({ user }: Props) => (
  <div>{get(user, 'address.street').filter(Boolean).join(', ')}</div>
)


Enter fullscreen mode Exit fullscreen mode

稍后,我们想重构这个数据结构并使用稍微不同的地址表示



type Address = {
  postCode: string
  street: {
    line1: string
    line2?: string
  }
}


Enter fullscreen mode Exit fullscreen mode

由于_.get始终返回any路径字符串,TypeScript 不会注意到任何问题,而代码将在运行时抛出,因为filter我们的新对象上不存在方法Address

添加类型

自 2020 年 11 月发布的 v4.1 版本以来,TypeScript 新增了一项名为“模板字面量类型”的功能。它允许我们使用字面量和其他类型构建模板。让我们看看它如何帮助我们。

解析点分隔路径

最常见的情况是,我们希望 TypeScript 能够根据对象内部的给定路径正确推断值的类型。对于上面的例子,我们希望知道一个类型,以便address.street能够及早发现更新数据结构的问题。我还将使用条件类型。如果您不熟悉条件类型,可以将其视为一个简单的三元运算符,它告诉您一个类型是否与另一个类型匹配。

首先,让我们检查一下我们的路径是否实际上是一组点分隔的字段



type IsDotSeparated<T extends string> = T extends `${string}.${string}`
  ? true
  : false

type A = IsDotSeparated<'address.street'> // true
type B = IsDotSeparated<'address'> // false


Enter fullscreen mode Exit fullscreen mode

看起来很简单,对吧?但是我们如何提取真正的密钥呢?
这里有一个神奇的关键字infer,它可以帮助我们获取字符串的部分内容。



type GetLeft<T extends string> = T extends `${infer Left}.${string}`
  ? Left
  : undefined

type A = GetLeft<'address.street'> // 'address'
type B = GetLeft<'address'> // undefined


Enter fullscreen mode Exit fullscreen mode

现在,是时候添加我们的对象类型了。让我们从一个简单的例子开始



type GetFieldType<Obj, Path> = Path extends `${infer Left}.${string}`
  ? Left extends keyof Obj
    ? Obj[Left]
    : undefined
  : Path extends keyof Obj
    ? Obj[Path]
    : undefined


type A = GetFieldType<UserInfo, 'address.street'> // Address, for now we only taking a left part of a path
type B = GetFieldType<UserInfo, 'address'> // Address
type C = GetFieldType<UserInfo, 'street'> // undefined


Enter fullscreen mode Exit fullscreen mode

首先,我们检查传入的路径是否与string.string模板匹配。如果匹配,则取出其左侧部分,检查该路径是否存在于对象的键中,并返回其字段类型。

如果路径与模板不匹配,则可能是一个简单的键。对于这种情况,我们会进行类似的检查并返回字段类型,或者undefined作为回退。

添加递归

好的,我们得到了顶级字段的正确类型。但它只提供了一点值。让我们改进我们的实用类型,并沿着这条路走到需要的值。

我们将:

  1. 查找顶级键
  2. 通过给定的键获取值
  3. 从我们的路径中删除此键
  4. 重复整个过程,直到我们的解析值和密钥的其余部分不Left.Right匹配为止


export type GetFieldType<Obj, Path> =
  Path extends `${infer Left}.${infer Right}`
    ? Left extends keyof Obj
      ? GetFieldType<Obj[Left], Right>
      : undefined
    : Path extends keyof Obj
      ? Obj[Path]
      : undefined

type A = GetFieldType<UserInfo, 'address.street'> // { line1: string; line2?: string | undefined; }
type B = GetFieldType<UserInfo, 'address'> // Address
type C = GetFieldType<UserInfo, 'street'> // undefined


Enter fullscreen mode Exit fullscreen mode

完美!看来这就是我们想要的。

处理可选属性

嗯,还有一种情况需要考虑。typeUserInfo有一个可选previousAddress字段。我们尝试获取previousAddress.streettype



type A = GetFieldType<UserInfo, 'previousAddress.street'> // undefined


Enter fullscreen mode Exit fullscreen mode

哎哟!但如果previousAddress设置了,street肯定不会是 undefined。

让我们弄清楚这里发生了什么。由于previousAddress是可选的,它的类型是Address | undefined(我假设你已经strictNullChecks启用了)。显然,street在 上不存在undefined,所以无法推断出正确的类型。

我们需要改进我们的GetField。为了获取正确的类型,我们需要移除undefined。但是,我们需要在最终类型中保留它,因为该字段是可选的,并且其值确实可能未定义。

我们可以使用两种 TypeScript 内置实用程序类型来实现这一点:
Exclude从给定联合中删除类型,Extract从给定联合中提取类型,或者never在没有匹配的情况下返回。



export type GetFieldType<Obj, Path> = Path extends `${infer Left}.${infer Right}`
  ? Left extends keyof Obj
    ? GetFieldType<Exclude<Obj[Left], undefined>, Right> | Extract<Obj[Left], undefined>
    : undefined
  : Path extends keyof Obj
    ? Obj[Path]
    : undefined

// { line1: string; line2?: string | undefined; } | undefined
type A = GetFieldType<UserInfo, 'previousAddress.street'>


Enter fullscreen mode Exit fullscreen mode

undefined存在于值类型中时,| Extract<>将其添加到结果中。否则,Extract返回never被忽略的值。

就是这样!现在我们有了一个很棒的实用程序类型,它将有助于提高我们的代码安全性。

实现效用函数

既然我们已经教会了 TypeScript 如何获取正确的值类型,接下来让我们添加一些运行时逻辑。我们希望函数将一个以点分隔的路径拆分成几部分,并缩减此列表以获取最终值。函数本身非常简单。



export function getValue<
  TData,
  TPath extends string,
  TDefault = GetFieldType<TData, TPath>
>(
  data: TData,
  path: TPath,
  defaultValue?: TDefault
): GetFieldType<TData, TPath> | TDefault {
  const value = path
    .split('.')
    .reduce<GetFieldType<TData, TPath>>(
      (value, key) => (value as any)?.[key],
      data as any
    );

  return value !== undefined ? value : (defaultValue as TDefault);
}


Enter fullscreen mode Exit fullscreen mode

我们必须添加一些丑陋的as any类型转换,因为

  1. 中间值确实可以是任何类型;
  2. Array.reduce期望初始值与结果具有相同的类型。然而,这里并非如此。此外,尽管有三个泛型类型参数,但我们不需要在那里提供任何类型。由于所有泛型都映射到函数参数,TypeScript 会在函数调用时根据实际值推断出这些类型。

使组件类型安全

让我们重新审视一下我们的组件。在最初的实现中,我们使用了lodash.get,它不会因为类型不匹配而引发错误。但是使用 new getValue,TypeScript 会立即开始报错。

TypeScript

添加对 [] 符号的支持

_.get支持类似 的键list[0].foo。让我们在我们的类型中实现相同的功能。同样,字面模板类型可以帮助我们从方括号中获取索引键。这次我不会一步一步讲解,而是在下面发布最终的类型和一些注释。



type GetIndexedField<T, K> = K extends keyof T 
  ? T[K]
  : K extends `${number}`
    ? '0' extends keyof T
      ? undefined
      : number extends keyof T
        ? T[number]
        : undefined
    : undefined

type FieldWithPossiblyUndefined<T, Key> =
  | GetFieldType<Exclude<T, undefined>, Key>
  | Extract<T, undefined>

type IndexedFieldWithPossiblyUndefined<T, Key> =
  | GetIndexedField<Exclude<T, undefined>, Key>
  | Extract<T, undefined>

export type GetFieldType<T, P> = P extends `${infer Left}.${infer Right}`
  ? Left extends keyof T
    ? FieldWithPossiblyUndefined<T[Left], Right>
    : Left extends `${infer FieldKey}[${infer IndexKey}]`
      ? FieldKey extends keyof T
        ? FieldWithPossiblyUndefined<IndexedFieldWithPossiblyUndefined<T[FieldKey], IndexKey>, Right>
        : undefined
      : undefined
  : P extends keyof T
    ? T[P]
    : P extends `${infer FieldKey}[${infer IndexKey}]`
      ? FieldKey extends keyof T
        ? IndexedFieldWithPossiblyUndefined<T[FieldKey], IndexKey>
        : undefined
      : undefined


Enter fullscreen mode Exit fullscreen mode

要从元组或数组中检索值,现在有一个新的GetIndexedField实用程序类型。它根据给定的键返回元组值,如果键超出元组范围则返回 undefined,如果是常规数组则返回元素类型。'0' extends keyof T条件会检查值是否为元组,因为数组没有字符串键。如果您知道更好的区分元组和数组的方法,请告诉我。

我们使用${infer FieldKey}[${infer IndexKey}]模板来解析field[0]各个部分。然后,使用Exclude | Extract与之前相同的技术,我们检索与可选属性相关的值类型。

现在我们需要稍微修改一下getValue函数。为了简单起见,我将用 替换.split('.').split(/[.[\]]/).filter(Boolean)支持新的表示法。这可能不是一个理想的解决方案,但更复杂的解析超出了本文的讨论范围。

这是最终的实现



export function getValue<
TData,
TPath extends string,
TDefault = GetFieldType<TData, TPath>
>(
data: TData,
path: TPath,
defaultValue?: TDefault
): GetFieldType<TData, TPath> | TDefault {
const value = path
.split(/[.[]]/)
.filter(Boolean)
.reduce<GetFieldType<TData, TPath>>(
(value, key) => (value as any)?.[key],
data as any
);

return value !== undefined ? value : (defaultValue as TDefault);
}

Enter fullscreen mode Exit fullscreen mode




结论

现在我们不仅拥有一个可以提高代码类型安全性的实用函数,而且还更好地理解了如何在实践中应用模板文字和条件类型。

希望本文对您有所帮助。感谢您的阅读。

所有代码均可在codesandbox获取

文章来源:https://dev.to/tipsy_dev/advanced-typescript-reinventing-lodash-get-4fhe
PREV
生成随机 JWT 密钥
NEXT
使用 Next.js 创建 Markdown 博客