变量、原始类型、字面量类型与类型推断

如果把每个变量后面都补上 : string、: number,代码会长不少,却未必更清楚。TypeScript 常常已经知道一个值的类型。我们真正需要学的,是看懂它推断出了什么,以及什么时候该补充说明。

这篇从一个网站名字和一个计数器开始。碰到新术语时,先看代码具体允许你做什么,再记术语。

1. 为什么这一行不用写类型

TypeScript
const siteName = "Knowledge Base" let requestCount = 0 requestCount += 1 // requestCount = "一次" // 类型检查会报错

把鼠标放到编辑器里的变量上,通常会看到:siteName 的类型是 "Knowledge Base",requestCount 的类型是 number。

后者很好理解:你用数字初始化了变量,以后可以继续存数字。TypeScript 不会因为你使用 let,就允许它随后变成字符串。

前者更具体。siteName 是一个不再重新赋值的字符串常量,所以 TypeScript 可以把它记成“就是这个字符串”,而不仅是“某个字符串”。这种把具体值本身作为类型的写法,叫字面量类型。

类型推断也会分析函数返回值:

TypeScript
function multiply(left: number, right: number) { return left * right } const total = multiply(6, 7) // 返回值和 total 都被推断为 number console.log(total) // 42

函数的两个参数在这里需要你说明类型,返回值则可以从乘法推断出来。对公开 API,主动写返回类型有助于固定约定;对简单的局部计算,不必重复编译器已经知道的信息。

const 固定的是变量绑定,不是整个对象

TypeScript
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:

TypeScript
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 虽然方便,却也会接受 "随便写"。可以把允许的值直接列出来:

TypeScript
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 都可以接住各种值,但拿到值之后,编译器的态度完全不同。

TypeScript
function unsafeLength(value: any): number { return value.length } console.log(unsafeLength(42)) // 实际得到 undefined,和声明的 number 不一致

any 让编译器放过了这里的属性访问。函数写了返回类型,也救不了这个未经检查的值。这类错误会顺着函数返回值继续传下去。

如果参数改成 unknown,你得先弄清楚它是什么,再执行对应操作:

TypeScript
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 到底差在哪

先看两个函数:

TypeScript
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. 有值才访问,没有值才用默认值

假设用户可能还没有个人资料:

TypeScript
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() 去掉空格后得到的 "" 不是空值。

这往往正是业务要决定的地方:如果空白昵称也应该显示默认名字,就把规则明确写出来:

TypeScript
function visibleName(nickname: string | undefined): string { const name = nickname?.trim() return name ? name : "Anonymous" }

|| 也会把 0、false 和空字符串当作需要替换的值,因此用它处理数字配置时要当心,合法的 0 可能被换掉。value! 这种非空断言则只影响编译器,不会在运行时帮你检查空值。

7. as const 和 satisfies 可以一起做什么

假设你在维护一个路由配置:

TypeScript
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 的整数。

下面是一份参考实现:

TypeScript
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 的说明。