在《如何用TypeScript编写React组件》中,我打了一个React组件的例子。这里是我们结束的地方:
const operations = {
'+': (left: number, right: number): number => left + right,
'-': (left: number, right: number): number => left - right,
'*': (left: number, right: number): number => left * right,
'/': (left: number, right: number): number => left / right,
}
type CalculatorProps = {
left: number
operator: keyof typeof operations
right: number
}
function Calculator({left, operator, right}: CalculatorProps) {
const result = operations[operator](left, right)
return (
<div>
<code>
{left} {operator} {right} = <output>{result}</output>
</code>
</div>
)
}
const examples = (
<>
<Calculator left={1} operator="+" right={2} />
<Calculator left={1} operator="-" right={2} />
<Calculator left={1} operator="*" right={2} />
<Calculator left={1} operator="/" right={2} />
</>
)
虽然我对operations 这个函数不满意。我知道该对象中的每个函数都会有完全相同的类型(由于用例的需要):
type OperationFn = (left: number, right: number) => number
operations 对象实际上只是一个操作字符串的记录,它被映射到一个对两个数字进行操作的函数。因此,如果我们在我们的operations 变量上添加一个类型注解,那么我们就不必对每个函数单独进行类型化。 让我们试试:
type OperationFn = (left: number, right: number) => number
const operations: Record<string, OperationFn> = {
'+': (left, right) => left + right,
'-': (left, right) => left - right,
'*': (left, right) => left * right,
'/': (left, right) => left / right,
}
type CalculatorProps = {
left: number
operator: keyof typeof operations
right: number
}
很好,所以我们不必单独输入每个函数,但是,哦,不......现在typeof operations 是Record<string, OperationFn> ,其中的keyof 将是string ,这意味着我们的CalculatorProps['operator'] 类型将是string 。唉 😩
这里是我们可以做的,以解决这个问题:
type OperationFn = (left: number, right: number) => number
type Operator = '+' | '-' | '/' | '*'
const operations: Record<Operator, OperationFn> = {
'+': (left, right) => left + right,
'-': (left, right) => left - right,
'*': (left, right) => left * right,
'/': (left, right) => left / right,
}
type CalculatorProps = {
left: number
operator: keyof typeof operations
right: number
}
但是现在我们又不得不在两个地方添加** ,如果我们决定添加指数运算符。然而,在这种情况下,如果我们在一个地方而不是另一个地方添加它,TypeScript会给我们一个编译器错误,所以这是个进步。
这是我第一次写这个组件时留下的痕迹,但后来@AlekseyL13建议我尝试使用正确类型的身份函数。
受约束的身份函数
首先,让我们牢记,我们有两个目标:
- 确保每个属性的类型是相同的(在这个简单的例子中,它只是一个数字,但在我们的实际例子中,它是一个函数类型
- 确保我们的对象的
keyof typeof,结果是键的有限联合。
在TypeScript中,要同时拥有这两点是个挑战。默认情况下,我们会得到第二个目标。问题是,当你试图用像const operations: Record<string, OperationFn> = ... 这样的类型注解来完成第一个目标时,你最终会扩大key ,所以keyof typeof 的结果是string 。唉,真烦人。
所以这就是约束性身份函数的用武之地。顺便说一下,"受限 "描述了一种情况,即你有一个函数接受比它所传递的输入更窄的版本。
type NamedObject = {name: string}
function getUserName<User extends NamedObject>(user: User) {
return user.name
}
const obj = {name: 'Hannah', age: 3}
getUserName(obj)
因此,传递给getUserName 的对象必须满足NamedObject 中的所有类型。getUserName 对输入进行约束,使其至少符合该类型。
而 "身份函数 "是一个接受一个值并返回该值的函数。我有时会使用这类函数作为回调的默认值。
const identity = <Type extends unknown>(item: Type) => item
type ModifyConfigFn = (config: ConfigType) => ConfigType
function buildProject(modifyConfig: ModifyConfigFn = identity) {
const config: ConfigType = {
/* some config */
}
const modifiedConfig = modifyConfig(config)
// more stuff...
}
因此,有了这些定义,"受限的身份函数 "是一个函数,它返回它所给的东西,并帮助TypeScript限制其类型。这正是我们想要做的。
我们可以把它叫做CIF(发音为 "seek eye eff")。当然,我们就用这个吧。
让我先给你看一个简单的例子,然后我再解释一下是怎么回事,然后我们可以把它更有效地应用到我们更复杂的例子中。
type Value = number
const createNumbers = <ObjectType extends Record<string, Value>>(
obj: ObjectType,
) => obj
const numbers = createNumbers({one: 1, two: 2, three: 3, four: 4})
// @ts-expect-error we don't have 'five' yet
numbers['five']
因此,createNumbers 是约束性的身份函数。它返回给定的obj ,希望这很清楚。但它是如何执行我们的输入并约束类型的呢?
让我这样解释。如果我们从。
const numbers = {one: 1, two: 2, three: 3, four: 4}
// typeof numbers:
// {
// one: number;
// two: number;
// three: number;
// four: number;
// }
但在未来,有人可能会来到这段代码,并像这样改变它。
const numbers = {one: 1, two: 2, three: 3, four: 4, five: '5'}
// typeof numbers:
// {
// one: number;
// two: number;
// three: number;
// four: number;
// five: string; // 😱
// }
Yikes!不,我们不能有这种情况!(而且,更重要的是,在我们的Calculator例子中,对函数进行一些自动打字是这里的目标)。
所以,让我们用一个类型注解来强制执行我们的值类型。
// @ts-expect-error HA! We gotcha! No strings in this object!
const numbers: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: '5',
}
但是现在通过明确地输入我们的值,我们已经告诉TypeScript,我们的key可以是一个字符串。不幸的是,我们没有办法告诉TypeScript。"这个东西有它的键,但值是这个特定的类型。"IMO,这是TypeScript缺失的一个功能。我们的createNumbers 约束身份函数(呃... "CIF")是一种变通方法。
所以,这就是那个变通的方法。
受限身份函数允许我们不明确地注释我们的变量,同时仍然可以强制执行值。
因此,我们创建对象,获得TypeScript可以提供给我们的最佳类型(包括窄键和宽值),然后我们把它传递给一个接受宽键和窄值的函数。TypeScript将这些结合起来,给了我们一个Record ,其中的key 和value 都是狭义的!
好了,让我们把CIF应用到我们原来的情况。
type OperationFn = (left: number, right: number) => number
const createOperations = <OperationsType extends Record<string, OperationFn>>(
operations: OperationsType,
) => operations
const operations = createOperations({
'+': (left, right) => left + right,
'-': (left, right) => left - right,
'*': (left, right) => left * right,
'/': (left, right) => left / right,
})
type CalculatorProps = {
left: number
operator: keyof typeof operations
right: number
}
// @ts-expect-error we haven't added support
// for the exponentiation operator yet
operations['**'](1, 2)
Wahoo!所以有了这个解决方案,我们不需要明确地以完全相同的方式输入所有的操作函数,我们仍然可以得到一个所有可用操作的联合类型。
一个通用的CIF?
你可能已经注意到,我们在上一节中有两个CIF。
type Value = number
const createNumbers = <ObjectType extends Record<string, Value>>(
obj: ObjectType,
) => obj
type OperationFn = (left: number, right: number) => number
const createOperations = <OperationsType extends Record<string, OperationFn>>(
operations: OperationsType,
) => operations
如果我们能把这些结合起来,那不是很好吗?当然会。但你不会喜欢它...这是我首先尝试的。
const constrain = <Given, Inferred extends Given>(item: Inferred) => item
// @ts-expect-error Expected 2 type arguments, but got 1.(2558)
const numbers = constrain<Record<string, number>>({one: 1 /* etc. */})
悲哀的一天。不幸的是,这在今天的TypeScript中是不可能的。但这里有一个变通办法。
const constrain =
<Given extends unknown>() =>
<Inferred extends Given>(item: Inferred) =>
item
const numbers = constrain<Record<string, number>>()({one: 1 /* etc. */})
......是的,我告诉你你不会喜欢它。这样做稍微好一点。
const createNumbers = constrain<Record<string, number>>()
const numbers = createNumbers({one: 1 /* etc. */})
但就像,呵呵。讨厌。
幸运的是,我发现自己并不经常做CIF,而且它们并不难写,所以我不需要一个抽象的东西。不过我觉得和大家分享一下还是很有意思的 😄
总结
这是我们的计算器组件的最终版本,所有的东西都用我们的CIF打出来了。
type OperationFn = (left: number, right: number) => number
const createOperations = <OperationsType extends Record<string, OperationFn>>(
opts: OperationsType,
) => opts
const operations = createOperations({
'+': (left, right) => left + right,
'-': (left, right) => left - right,
'*': (left, right) => left * right,
'/': (left, right) => left / right,
})
type CalculatorProps = {
left: number
operator: keyof typeof operations
right: number
}
function Calculator({left, operator, right}: CalculatorProps) {
const result = operations[operator](left, right)
return (
<div>
<code>
{left} {operator} {right} = <output>{result}</output>
</code>
</div>
)
}
const examples = (
<>
<Calculator left={1} operator="+" right={2} />
<Calculator left={1} operator="-" right={2} />
<Calculator left={1} operator="*" right={2} />
<Calculator left={1} operator="/" right={2} />
</>
)
是的,写这篇博文就是为了向你解释这三行代码。所以,是的,你去吧。
有些人读完这篇文章后可能会嗤之以鼻,说什么。"如果TypeScript要求你做这样奇怪的事情,你为什么还要使用它?"
首先,我想说的是,像TypeScript这样的工具需要解决一些类似的问题,并不意味着它不值得使用。这里的成本是最小的,好处是很大的。我不是来说服你使用TypeScript的,我做不到像你的运行时错误那样说服你,我相信😜其次,这绝对是TypeScript未来可以改进的地方。事实上,这可能是改善事情的一个很好的步骤。 最后,就像我说的,这并不是我们一直在做的事情。我与TypeScript的大部分时间都是愉快的。
照顾好自己!