进阶 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]
}
}
现在我们要渲染它
import { get } from 'lodash'
type Props = {
user: UserInfo
}
export const Address = ({ user }: Props) => (
<div>{get(user, 'address.street').filter(Boolean).join(', ')}</div>
)
稍后,我们想重构这个数据结构并使用稍微不同的地址表示
type Address = {
postCode: string
street: {
line1: string
line2?: string
}
}
由于_.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
看起来很简单,对吧?但是我们如何提取真正的密钥呢?
这里有一个神奇的关键字infer,它可以帮助我们获取字符串的部分内容。
type GetLeft<T extends string> = T extends `${infer Left}.${string}`
? Left
: undefined
type A = GetLeft<'address.street'> // 'address'
type B = GetLeft<'address'> // undefined
现在,是时候添加我们的对象类型了。让我们从一个简单的例子开始
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
首先,我们检查传入的路径是否与string.string
模板匹配。如果匹配,则取出其左侧部分,检查该路径是否存在于对象的键中,并返回其字段类型。
如果路径与模板不匹配,则可能是一个简单的键。对于这种情况,我们会进行类似的检查并返回字段类型,或者undefined
作为回退。
添加递归
好的,我们得到了顶级字段的正确类型。但它只提供了一点值。让我们改进我们的实用类型,并沿着这条路走到需要的值。
我们将:
- 查找顶级键
- 通过给定的键获取值
- 从我们的路径中删除此键
- 重复整个过程,直到我们的解析值和密钥的其余部分不
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
完美!看来这就是我们想要的。
处理可选属性
嗯,还有一种情况需要考虑。typeUserInfo
有一个可选previousAddress
字段。我们尝试获取previousAddress.street
type
type A = GetFieldType<UserInfo, 'previousAddress.street'> // undefined
哎哟!但如果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'>
当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);
}
我们必须添加一些丑陋的as any
类型转换,因为
- 中间值确实可以是任何类型;
Array.reduce
期望初始值与结果具有相同的类型。然而,这里并非如此。此外,尽管有三个泛型类型参数,但我们不需要在那里提供任何类型。由于所有泛型都映射到函数参数,TypeScript 会在函数调用时根据实际值推断出这些类型。
使组件类型安全
让我们重新审视一下我们的组件。在最初的实现中,我们使用了lodash.get
,它不会因为类型不匹配而引发错误。但是使用 new getValue
,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
要从元组或数组中检索值,现在有一个新的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);
}
结论
现在我们不仅拥有一个可以提高代码类型安全性的实用函数,而且还更好地理解了如何在实践中应用模板文字和条件类型。
希望本文对您有所帮助。感谢您的阅读。
所有代码均可在codesandbox获取
文章来源:https://dev.to/tipsy_dev/advanced-typescript-reinventing-lodash-get-4fhe