变量、原始类型、字面量类型与类型推断
如果把每个变量后面都补上 : string、: number,代码会长不少,却未必更清楚。TypeScript 常常已经知道一个值的类型。我们真正需要学的,是看懂它推断出了什么,以及什么时候该补充说明。
这篇从一个网站名字和一个计数器开始。碰到新术语时,先看代码具体允许你做什么,再记术语。
1. 为什么这一行不用写类型
const siteName = "Knowledge Base"
let requestCount = 0
requestCount += 1
// requestCount = "一次" // 类型检查会报错
把鼠标放到编辑器里的变量上,通常会看到:siteName 的类型是 "Knowledge Base",requestCount 的类型是 number。
后者很好理解:你用数字初始化了变量,以后可以继续存数字。TypeScript 不会因为你使用 let,就允许它随后变成字符串。
前者更具体。siteName 是一个不再重新赋值的字符串常量,所以 TypeScript 可以把它记成“就是这个字符串”,而不仅是“某个字符串”。这种把具体值本身作为类型的写法,叫字面量类型。
类型推断也会分析函数返回值:
function multiply(left: number, right: number) {
return left * right
}
const total = multiply(6, 7) // 返回值和 total 都被推断为 number
console.log(total) // 42
函数的两个参数在这里需要你说明类型,返回值则可以从乘法推断出来。对公开 API,主动写返回类型有助于固定约定;对简单的局部计算,不必重复编译器已经知道的信息。
const 固定的是变量绑定,不是整个对象
const user = { name: "Ada" }
user.name = "Lin" // 合法,对象内部仍然可以修改
// user = { name: "Mo" } // 错误,const 变量不接受重新赋值
因此,日常可以先用 const,确实需要重新赋值时再换成 let。var 仍是 JavaScript 语法,但它的函数作用域、重复声明和提升行为更容易让初学者混淆,这里先不使用。
2. 数字、字符串和空值分别怎么写
下面这些类型对应 JavaScript 的原始值,不是 TypeScript 新造出的一套运行时数据:
| 类型 | 一个例子 | 初学时容易混淆的地方 |
|---|---|---|
string |
"Ada" |
类型通常写小写 string;String 描述的是包装对象相关类型 |
number |
42、3.14 |
没有单独的 int 类型;整数和小数都在这里 |
bigint |
42n |
用于大整数,和 number 是两种类型 |
boolean |
true |
字符串 "false" 不是布尔值 false |
symbol |
Symbol("id") |
每次创建的 symbol 都是独立的值,即使描述相同 |
undefined |
undefined |
常见于缺少属性、未传参数、函数没有返回结果 |
null |
null |
常用来主动表示空值;具体含义仍由程序约定 |
number 使用双精度浮点数。精确表示整数的安全范围是 -(2^53 - 1) 到 2^53 - 1,上界可以用 Number.MAX_SAFE_INTEGER 读取。超过这个范围,不是每个整数都还能被精确表示。
如果确实需要大整数,可以使用 bigint:
const largeId = 9_007_199_254_740_993n
console.log(largeId + 1n) // 9007199254740994n
// largeId + 1 // bigint 和 number 不支持这样直接相加
const idKey: unique symbol = Symbol("id")
const row = { [idKey]: "u-1" }
console.log(row[idKey]) // "u-1"
这里的 unique symbol 还让类型系统记住这个 symbol 的独特身份。暂时只要知道它适合用作不易与普通字符串字段重名的键,不必立刻把所有对象字段改成 symbol。
3. 只允许几个选项时,用字面量联合
假设一个函数目前只支持四种 HTTP 方法。参数写成 string 虽然方便,却也会接受 "随便写"。可以把允许的值直接列出来:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"
type RetryCount = 0 | 1 | 2 | 3
function request(method: HttpMethod, retries: RetryCount): void {
console.log({ method, retries })
}
request("GET", 2)
// request("PATCH", 2) // 当前这个类型没有列出 PATCH
竖线 | 表示“其中一种”。所以 HttpMethod 是四种字符串字面量类型组成的联合类型。
这个函数只是展示参数检查,并没有真的发送 HTTP 请求。例子没有列出 PATCH,也不代表 HTTP 没有这种方法;这里只是在表达这个函数当前支持的范围。
这样的写法既有编辑器补全,也能抓住拼写错误,而且类型被擦除后不会额外生成枚举对象。后面学习分支处理时,还会用它检查是否漏掉某个选项。
4. 不知道数据是什么时,为什么优先用 unknown
any 和 unknown 都可以接住各种值,但拿到值之后,编译器的态度完全不同。
function unsafeLength(value: any): number {
return value.length
}
console.log(unsafeLength(42)) // 实际得到 undefined,和声明的 number 不一致
any 让编译器放过了这里的属性访问。函数写了返回类型,也救不了这个未经检查的值。这类错误会顺着函数返回值继续传下去。
如果参数改成 unknown,你得先弄清楚它是什么,再执行对应操作:
function format(input: unknown): string {
if (typeof input === "string") return input.trim()
if (typeof input === "number") return input.toFixed(2)
if (input === null) return "(空值)"
return "(暂不支持这种数据)"
}
console.log(format(" Ada ")) // Ada
console.log(format(3)) // 3.00
console.log(format({ name: "Ada" })) // (暂不支持这种数据)
在第一个分支中,typeof 已经证明输入是字符串,所以可以调用 trim()。第二个分支同理。这种根据判断条件缩小可能类型的过程叫收窄。
这里没有把最后一个分支简单写成 JSON.stringify(input):这个 API 并不保证对所有输入都返回字符串,某些输入会得到 undefined,循环引用和部分其他输入还会抛错。格式化函数想怎么处理这些情况,应当自己做决定,而不是让一个看似通用的写法遮住问题。
网络响应、用户输入和解析后的 JSON,都适合先当作 unknown 来处理。它不是完成了验证,而是在提醒后续代码:这里还需要验证。
5. void 和 never 到底差在哪
先看两个函数:
function logMessage(message: string): void {
console.log(message)
}
function fail(message: string): never {
throw new Error(message)
}
调用 logMessage 后,程序通常继续执行下一行,只是不需要使用它的返回结果。这个函数没有 return 表达式,JavaScript 实际返回 undefined。
调用 fail 后,它会抛出异常,不会正常返回到下一行,所以标成 never。never 也会出现在某个分支已经排除了所有可能类型的时候,后面的联合类型章节会接着讲。
还有一个稍后读回调代码时会遇到的细节:回调位置的 () => void 表示调用者不使用返回值,并不保证实现函数绝对不返回任何东西。例如返回数字的函数,可以被用在忽略返回值的回调位置。这和上面显式标了 : void 的函数体中随意返回数字,是两回事。
6. 有值才访问,没有值才用默认值
假设用户可能还没有个人资料:
type Profile = { nickname?: string }
function displayName(profile: Profile | null): string {
return profile?.nickname?.trim() ?? "Anonymous"
}
console.log(displayName(null)) // Anonymous
console.log(displayName({ nickname: " Ada " })) // Ada
console.log(displayName({ nickname: " " })) // 空字符串
nickname? 表示属性可以缺省。profile?.nickname 则是在运行时访问属性前,先看看 profile 是不是 null 或 undefined。后一个 ?. 对昵称做同样的判断。
?? 只在左边是 null 或 undefined 时使用默认值。最后一个调用返回空字符串,是因为 trim() 去掉空格后得到的 "" 不是空值。
这往往正是业务要决定的地方:如果空白昵称也应该显示默认名字,就把规则明确写出来:
function visibleName(nickname: string | undefined): string {
const name = nickname?.trim()
return name ? name : "Anonymous"
}
|| 也会把 0、false 和空字符串当作需要替换的值,因此用它处理数字配置时要当心,合法的 0 可能被换掉。value! 这种非空断言则只影响编译器,不会在运行时帮你检查空值。
7. as const 和 satisfies 可以一起做什么
假设你在维护一个路由配置:
type Route = {
path: `/${string}`
method: "GET" | "POST"
}
const healthRoute = {
path: "/health",
method: "GET",
} as const satisfies Route
先从外往里读:这份对象应该符合 Route,路径以 / 开头,方法取两种值之一。
satisfies Route检查这份对象是否满足要求,而不是简单把变量的类型整体改写成Route。它参与类型检查,也可能影响表达式的推断,所以不要把它理解为对推断绝对没有影响。as const尽量保留这里的具体字面量,并让字面量中的属性成为只读类型。读取healthRoute.method时,编译器知道这里是"GET",不只是一个任意字符串。
两者都不会调用 Object.freeze()。尤其当对象里引用了一个提前创建的可变数组时,as const 也不会自动把那个已有数组变成深层不可变数据。这里讨论的是类型检查约束,不是运行时的冻结机制。
如果只是想让错误提示消失,先别急着写 as。优先补上必要判断、明确函数参数,或者检查类型声明是否准确。连续写 as unknown as 某个类型 很容易把原本需要解决的不一致藏起来。
8. 练习:把端口号读成一个整数
现在试着写 parsePort(input: unknown): number。我们先把要求讲清楚:接受数字或仅由十进制数字组成的字符串;字符串两端的空白可以忽略;结果必须是 1 到 65535 的整数。
下面是一份参考实现:
function parsePort(input: unknown): number {
let port: number
if (typeof input === "number") {
port = input
} else if (typeof input === "string" && /^\d+$/.test(input.trim())) {
port = Number(input.trim())
} else {
throw new TypeError("端口号应该是数字或十进制数字字符串")
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new RangeError("端口号应该是 1 到 65535 之间的整数")
}
return port
}
console.log(parsePort(" 8080 ")) // 8080
console.log(parsePort(443)) // 443
// parsePort("1e3") // 不符合这里约定的字符串格式
// parsePort(NaN) // 不符合整数要求
// parsePort(65536) // 超出范围
Number("1e3") 本身会得到 1000,但我们这次约定的是十进制数字字符串,所以先用正则限制格式,再转换。这个先后顺序来自输入规则,不是 TypeScript 自动替你决定的。
你可以再测试 ""、"0"、3.5、Infinity 和 null,分别判断它们会在哪个分支停下来。能顺着这些输入解释代码,说明你已经开始把类型和实际程序行为联系起来了。
需要核对语法时可查:日常类型、类型收窄、satisfies 的说明。