第 24 条: 理解上下文在类型推断中的作用
要点
- 了解上下文如何在类型推断中被使用。
- 如果提取变量时引入了类型错误,可以考虑添加类型注解。
- 如果变量确实是常量,使用
const
断言(as const
)。但要注意,这可能会导致错误在使用时而非定义时出现。 - 在可行的情况下,优先内联值,以减少对类型注解的需求。
正文
TypeScript 不只是根据值来推断类型,它还会考虑值所处的上下文环境。这种做法大多数时候都很智能,但有时也会带来一些意想不到的问题。理解 TypeScript 如何使用上下文来进行类型推断,可以帮助你在遇到这些“惊喜”时更好地应对。
在 JavaScript 中,只要不改变执行顺序,你完全可以把某个表达式提取出来赋值给常量,而不会影响代码的行为。换句话说,下面这两段代码在逻辑上是一样的:
// Inline form
setLanguage('JavaScript')
// Reference form
let language = 'JavaScript'
setLanguage(language)
在 TypeScript 中,这样的重构依然是可行的:
function setLanguage(language: string) {
/* ... */
}
setLanguage('JavaScript') // OK
let language = 'JavaScript'
setLanguage(language) // OK
但如果你认真采纳了第 35 条建议,用更精确的字符串字面量联合类型来替代普通的 string
类型,比如这样:
type Language = 'JavaScript' | 'TypeScript' | 'Python'
function setLanguage(language: Language) {
/* ... */
}
setLanguage('JavaScript') // OK
let language = 'JavaScript'
setLanguage(language)
// ~~~~~~~~ Argument of type 'string' is not assignable
// to parameter of type 'Language'
出了什么问题?在内联形式中,TypeScript 从函数声明中就知道参数应该是 Language 类型。字符串字面量 'JavaScript' 可以赋值给这个类型,所以没问题。但当你把它抽取成变量时,TypeScript 必须在赋值时推断变量的类型。它会用常规算法(见第 20 条)推断出 string 类型,而 string 不能赋值给 Language 类型,因此报错。
NOTE
有些语言可以根据变量的最终用途来推断类型,但这也可能让人困惑。TypeScript 的创造者 Anders Hejlsberg 把这种现象称为“远距离的幽灵行动”。大多数情况下,TypeScript 会在变量第一次出现时确定它的类型。想了解此规则的一个显著例外,请参见第 25 条。
解决这个问题有两种好方法。其中一种是用类型注解来限制 language 变量的可能取值:
let language: Language = 'JavaScript'
setLanguage(language) // OK
这还有一个好处:如果 language 写错了,比如写成 'Typescript'(应该是大写的“S”),它会报错提醒你。
另一种解决方案是把变量声明为常量:
const language = 'JavaScript'
// ^? const language: "JavaScript"
setLanguage(language) // OK
用 const 表示这个变量不会变,所以 TypeScript 能推断出更精确的类型——字符串字面量类型 "JavaScript"。这个类型是可以赋值给 Language 的,所以代码能通过类型检查。当然,如果你需要重新赋值,就必须用类型注解了。
这里的根本问题是,我们把值和它被使用的上下文分开了。有时候这样没问题,但很多时候会出错。接下来会讲几个因失去上下文导致错误的例子,并教你怎么修复。
元组类型(Tuple Types)
除了字符串字面量类型,元组类型也会有类似问题。比如你在写一个地图的程序,可以用代码控制地图平移:
// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) {
/* ... */
}
panTo([10, 20]) // OK
const loc = [10, 20]
// ^? const loc: number[]
panTo(loc)
// ~~~ Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'
和之前一样,你把值和上下文分开了。[10, 20]
直接用时符合元组类型 [number, number]
。但是 loc
被推断成了 number[]
,也就是长度不确定的数字数组,这就不能赋值给严格长度的元组。
怎么解决呢?你已经用了 const,不能再用它了,但可以加类型注解告诉 TypeScript 你具体想要什么:
const loc: [number, number] = [10, 20]
panTo(loc) // OK
另一种方法是用“const 上下文”告诉 TypeScript 你想让这个值是深度不可变的,而不仅仅是浅层 const:
const loc = [10, 20] as const
// ^? const loc: readonly [10, 20]
panTo(loc)
// ~~~ The type 'readonly [10, 20]' is 'readonly'
// and cannot be assigned to the mutable type '[number, number]'
这时 loc
的类型是 readonly [10, 20]
,比之前更精确了。但这个类型是只读的,而 panTo
函数的参数是可变的,所以不能赋值过去。
最好的办法是给 panTo
函数加个 readonly
注解:
function panTo(where: readonly [number, number]) {
/* ... */
}
const loc = [10, 20] as const
panTo(loc) // OK
如果你没法改 panTo
的定义,那就只能用类型注解了。(第 14 条讲了 readonly 和类型安全的更多内容。)
使用 const
上下文能解决推断丢失上下文的问题,但有个缺点:如果你定义错了(比如给元组多加了一个元素),错误不会在定义处报,而是在调用处报,可能很难发现:
const loc = [10, 20, 30] as const // error is really here.
panTo(loc)
// ~~~ Argument of type 'readonly [10, 20, 30]' is not assignable to
// parameter of type 'readonly [number, number]'
// Source has 3 element(s) but target allows only 2.
因此,最好还是用内联形式或者加类型声明。
对象类型
把值和上下文分开也会在对象里出错,特别是对象里有字符串字面量或元组时。例如:
type Language = 'JavaScript' | 'TypeScript' | 'Python'
interface GovernedLanguage {
language: Language
organization: string
}
function complain(language: GovernedLanguage) {
/* ... */
}
complain({ language: 'TypeScript', organization: 'Microsoft' }) // OK
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
}
complain(ts)
// ~~ Argument of type '{ language: string; organization: string; }'
// is not assignable to parameter of type 'GovernedLanguage'
// Types of property 'language' are incompatible
// Type 'string' is not assignable to type 'Language'
这里 ts.language
被推断成了 string 类型,而不是具体的 Language 类型。解决办法同样是加类型注解:
const ts: GovernedLanguage = {
language: 'TypeScript',
organization: 'Microsoft',
}
或者用 as const
断言,或者用 satisfies
操作符(见第 20 条)。
回调函数
当你把回调函数作为参数传给别的函数时,TypeScript 会用上下文推断回调的参数类型:
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random())
}
callWithRandomNumbers((a, b) => {
// ^? (parameter) a: number
console.log(a + b)
// ^? (parameter) b: number
})
但如果你把回调单独提出来,失去上下文,TypeScript 就推断不出来,会报 noImplicitAny 错误:
const fn = (a, b) => {
// ~ Parameter 'a' implicitly has an 'any' type
// ~ Parameter 'b' implicitly has an 'any' type
console.log(a + b)
}
callWithRandomNumbers(fn)
解决办法是给参数加类型注解:
const fn = (a: number, b: number) => {
console.log(a + b)
}
callWithRandomNumbers(fn)
或者给整个函数表达式加类型声明(见第 12 条)。如果函数只用一次,推荐用内联写法,省去额外注解。
关键点总结
- 了解上下文如何在类型推断中被使用。
- 如果提取变量时引入了类型错误,可以考虑添加类型注解。
- 如果变量确实是常量,使用
const
断言(as const
)。但要注意,这可能会导致错误在使用时而非定义时出现。 - 在可行的情况下,优先内联值,以减少对类型注解的需求。