第 2 章

类型推断与标注边界:什么时候必须手写类型

第2章:类型推断与标注边界:什么时候必须手写类型

TypeScript 的类型推断能覆盖多大范围?这是理解本章内容的出发点。

本章核心问题:TypeScript 的类型推断能覆盖多大范围?什么时候必须手动写类型标注?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

TypeScript 能自动推断什么

TypeScript 编译器在大多数情况下比你更擅长推断类型。能不写的类型注解,就不要写——冗余注解不仅增加噪音,还会在重构时产生额外维护成本。

变量初始化时 — 不需要写

// 冗余,编译器已经知道
const name: string = "Alice";
const count: number = 42;
const active: boolean = true;

// 正确:让编译器推断
const name = "Alice";    // 推断为 string
const count = 42;        // 推断为 number
const active = true;     // 推断为 boolean(不是 true!)

函数返回值 — 通常不需要写

// 编译器能推断返回类型为 number
function add(a: number, b: number) {
  return a + b;
}

// 编译器能推断返回类型为 string | null
function findUser(id: number) {
  if (id === 0) return null;
  return `user_${id}`;
}

对象字面量 — 不需要写

// 编译器推断出完整的对象结构
const config = {
  host: "localhost",
  port: 3000,
  debug: true,
};
// 推断类型:{ host: string; port: number; debug: boolean }

必须手写类型的 5 种情况

情况 1:函数参数

这是最常见的必须手写的场景。参数没有初始值,编译器无从推断。

// 错误:参数 a, b 被推断为 any(在 strict 模式下报错)
function add(a, b) {
  return a + b;
}

// 正确
function add(a: number, b: number): number {
  return a + b;
}

情况 2:函数返回类型(作为合约时)

当函数是公共 API 或需要明确约定返回类型时,手写返回类型可以防止实现意外偏离合约。

// 没有返回类型注解:如果不小心漏掉了某个 return 分支,
// 编译器推断的类型会变成 string | undefined,调用方被迫处理
function getUserName(id: number) {
  const users: Record<number, string> = { 1: "Alice", 2: "Bob" };
  if (users[id]) return users[id]; // 漏掉了 else return "" 这样的情况
}

// 有返回类型注解:编译器强制你覆盖所有分支
function getUserName(id: number): string {
  const users: Record<number, string> = { 1: "Alice", 2: "Bob" };
  return users[id] ?? "Unknown"; // 必须返回 string,不能是 undefined
}

情况 3:声明后赋值(先声明,后初始化)

// 编译器推断 user 为 any,失去类型保护
let user;
user = fetchUser(); // 这里 user 是 any

// 正确:声明时指定类型
let user: User;
user = fetchUser(); // 编译器检查 fetchUser() 是否返回 User

情况 4:函数接收的对象参数需要明确形状

// 调用方不清楚应该传什么字段
function createUser(options) { // options 是 any
  return { name: options.name, role: options.role };
}

// 正确:用 interface 或 type 明确约定
interface CreateUserOptions {
  name: string;
  role: "admin" | "user";
  email?: string;
}

function createUser(options: CreateUserOptions) {
  return { name: options.name, role: options.role };
}

情况 5:数组初始化为空数组

// 空数组被推断为 never[],后续 push 会报错
const items = [];
items.push("hello"); // 错误:Argument of type 'string' is not assignable to 'never'

// 正确:指定数组元素类型
const items: string[] = [];
items.push("hello"); // ✅

类型拓宽(Type Widening)

TypeScript 在推断类型时会自动"拓宽",这个行为有时出乎意料。

// let 声明:类型被拓宽为 string
let status = "active";  // 推断为 string,不是 "active"
status = "inactive";    // ✅ 合法

// const 声明:类型被收窄为字面量类型
const status = "active";  // 推断为 "active"(字面量类型)

实际问题:

function setStatus(status: "active" | "inactive") {
  console.log(status);
}

let s = "active";   // s 的类型是 string,不是 "active"
setStatus(s);       // 错误:string 不能赋给 "active" | "inactive"

const s = "active"; // s 的类型是 "active"
setStatus(s);       // ✅ 合法

as const 锁定字面量类型:

const config = {
  method: "GET",
  timeout: 3000,
};
// 推断类型:{ method: string; timeout: number }
// method 是 string,传给只接受 "GET" | "POST" 的参数会报错

const config = {
  method: "GET",
  timeout: 3000,
} as const;
// 推断类型:{ readonly method: "GET"; readonly timeout: 3000 }
// 现在 method 是字面量类型 "GET",完全类型安全

satisfies:两全其美

TypeScript 4.9 引入的 satisfies 操作符,既保留推断类型,又验证结构符合要求。

type Colors = "red" | "green" | "blue";
type Palette = Record<Colors, string | [number, number, number]>;

// 用类型注解:丢失了具体类型信息
const palette: Palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
};
palette.green.toUpperCase(); // 错误:string | [number,number,number] 没有 toUpperCase

// 用 satisfies:验证结构 + 保留推断类型
const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
} satisfies Palette;
palette.green.toUpperCase(); // ✅ 编译器知道 green 是 string
palette.red[0];              // ✅ 编译器知道 red 是数组

Level 2 · 它是怎么运行的(3-5年经验)

推断边界:何时停止信任推断

不要信任的情况:

// JSON.parse 返回 any — 危险,立刻标注类型
const data = JSON.parse(response.body); // any
data.user.name; // 零类型保护,运行时可能崩溃

// 正确做法:用 zod 或手动断言(第18章详解 zod)
const data = JSON.parse(response.body) as ApiResponse;
// 或更安全:
const data: ApiResponse = JSON.parse(response.body);

fetch 返回值是 any 的陷阱:

// response.json() 返回 Promise<any> — 同样危险
const res = await fetch("/api/users");
const users = await res.json(); // any

// 正确:封装一个类型安全的 fetch
async function typedFetch<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

const users = await typedFetch<User[]>("/api/users");
// users 的类型是 User[],有完整类型保护

Level 3 · 规范怎么定义的(资深)

TypeScript 的类型推断算法基于 Hindley-Milner 类型推断的变体,但做了大量简化以适应 JavaScript 的灵活性。与 OCaml/Haskell 不同,TypeScript 不做全局类型推断——它只在局部作用域内推断,且总是以程序员显式标注为最高优先级。satisfies 操作符(TS 4.9)的引入是对 "上下文类型" 和 "推断类型" 之间张力的一次调和:它验证结构是否匹配约束,同时保留推断的精确类型信息。

Level 4 · 边界与陷阱(所有人)

JSON.parse 返回 any 的陷阱:这是最常见的类型推断失败场景。JSON.parse 的返回类型是 any,会污染整条调用链。始终用 unknown 接收,再用 zod 或手动类型守卫验证。

let vs const 推断差异let s = "active" 推断为 stringconst s = "active" 推断为字面量类型 "active"。在传递给接受字面量联合的函数时,这个差异会导致意外的编译错误。

fetch 返回 Promise<any> 的隐患response.json() 返回 Promise<any>,封装一个类型安全的 typedFetch<T> 是项目初期就应该做的事。


本章要点

场景 推荐做法
变量初始化 不写,让编译器推断
函数参数 必须手写
函数返回值 公共 API 写,私有函数可省略
空数组 必须手写元素类型
先声明后赋值 必须手写
字面量精度要求高 constas const
验证结构同时保留推断 satisfies
JSON.parse / fetch 立刻标注目标类型

下一章: 联合类型、交叉类型、字面量类型 — 判别联合(Discriminated Union)如何彻底替代 if-else 链。

本章评分
4.6  / 5  (95 评分)

💬 留言讨论