TypeScript 语法要点

要点

类型把”数据长什么样”从注释和人脑搬进编译器。联合写”或”,交叉写”且”,泛型把类型做成参数,收窄让编译器替你跟踪”现在到底是哪一种”。这篇讲每个语法为什么存在——语法本身是最短的部分,选型理由才是要背下来的东西。

一、类型标注与类型推断

类型标注(type annotation)是契约,类型推断(type inference)让契约不必重复抄写。编译器能从右侧推出的类型,左边再写一遍就是噪音;真正必须手写的,是推断没有信息来源的位置——函数参数,以及需要对外固化的导出 API。

let count = 0;             // 推断为 number,标注是废话
const names = ["a", "b"];  // 推断为 string[]

// 参数没有推断来源,必须标注——这是标注的主战场
function greet(name: string): string {
  return `你好,${name}`;
}

导出函数的返回值建议显式标注:它把你对调用方的承诺写死,实现内部改动一旦影响返回形状,第一时间在定义处报错,而不是散落在几十个调用点。

坑:初始化推断会把变量类型焊死在初始值上。let config = getDefault() 之后想赋一个更宽的值,得显式标注。标注密度不是越高越好——边界手写,内部交给推断。

二、interface 与 type:描述形状的两种写法

90% 的场景两者等价,差异在能力边缘。选哪个看团队约定,但边缘必须认识:

能力 interface type 别名
对象 / 函数形状 可以 可以
继承扩展 extends,多继承 交叉类型 & 模拟
同名重复声明 自动合并(declaration merging) 报错
联合、元组、映射、条件类型 不行 可以

declaration merging 双刃剑:库作者靠它扩展全局类型;业务代码里两个同名 interface 静默合并,排查半天才发现字段来自八竿子打不着的文件。描述对象形状且要被别人扩展,用 interface;要表达联合、要组合类型运算,只有 type 能干。

三、联合与交叉

| 是”值属于其中之一”,& 是”必须同时满足”。联合的规矩:收窄之前只能访问公共成员——这不是限制,是逼你在正确的位置处理分支。

function format(id: string | number): string {
  // id.toUpperCase()  // 报错:number 上没有这个方法
  return typeof id === "string" ? id.toUpperCase() : id.toFixed(0);
}

交叉类型的坑:同名属性类型冲突会交叉成 never,造出一个无人能构造的类型:

type A = { kind: "a"; value: string };
type B = { kind: "b"; value: number };
type Both = A & B;  // kind: "a" & "b" = never,这就是个死类型

四、泛型

泛型(generics)解决的问题:让输入的类型决定输出的类型,把这条关联保住。参数写 any,关联丢失;每个类型复制一份函数,是体力活。泛型就是把”类型”本身做成参数:

function firstOr<T>(list: T[], fallback: T): T {
  // 没有泛型:list 只能是 any[],返回 any,关联消失
  return list.length > 0 ? list[0] : fallback;
}
const n = firstOr([1, 2], 0);        // n 是 number,关联保住了

约束(constraint)用 extends,给类型参数立法——无约束的 T 上连 .length 都不能访问:

function logSize<T extends { length: number }>(x: T): T {
  console.log(x.length);  // 有约束才敢访问
  return x;
}

反模式:为泛型而泛型。函数体根本用不到类型关联时,泛型只是装饰品——单形态就直写具体类型,等第二形态出现再抽象。

五、类型收窄与判别联合(本篇重点)

联合类型只有配上收窄(narrowing)才有用。编译器沿控制流分析把宽类型削窄,触发器包括 typeof、真值检查、in、instanceof。而最值得练成肌肉记忆的,是判别联合(discriminated union)。

为什么它重要:多个布尔并存的状态(isLoading、hasError、hasData)挡不住”loading 且 error”这种矛盾组合;判别联合给每个成员一个字面量判别字段,switch 它,每个分支内类型自动收窄——非法状态不可表示。

type LoadState =
  | { status: "loading" }
  | { status: "success"; items: string[] }
  | { status: "error"; message: string };

function render(state: LoadState): string {
  switch (state.status) {
    case "loading": return "加载中";
    case "success": return `${state.items.length} 条`; // 此分支 items 必在
    case "error":   return `失败:${state.message}`;
  }
}

穷尽检查(exhaustiveness check):新增成员时漏写分支应该报编译错误,而不是运行时悄悄掉进 undefined:

function assertNever(x: never): never {
  throw new Error(`漏了分支: ${JSON.stringify(x)}`);
}
// switch 末尾加 default: return assertNever(state)
// 一旦 LoadState 加了第四个成员而 render 忘了处理,tsc 直接拦下

六、unknown 与 any

any 的本质是双向关闭检查,并且传染:任何经过 any 之手的值都失去保护。unknown 是”我不知道,但你得先证明”——可以随便赋值给它,但不能直接使用,必须先收窄或校验。

所以边界数据——JSON.parse 的返回、process.argv、接口响应——一律 unknown 起步:

const raw: unknown = JSON.parse(text);
// raw.items  // 编译报错,好事:没证明过形状就不许用
if (isTodoList(raw)) {
  render(raw);  // 类型谓词收窄后才放行
}

七、工具类型:类型层面的函数

工具类型(utility types)存在的理由:类型也需要复用。Partial 这类内置泛型就是”接受类型、返回类型”的函数,不必每次手写形状变体:

工具类型 语义 典型用途
Partial 所有属性变可选 更新接口的入参:只传要改的
Pick 挑出指定字段 列表视图只要 id 和标题
Record 键值映射 id → 实体的索引表
ReturnType 取函数返回类型 复用工厂函数产出的类型
interface Todo { id: string; text: string; done: boolean; }

type TodoDraft = Partial<Todo>;                 // 更新入参
type TodoBrief = Pick<Todo, "id" | "text">;     // 列表视图
type TodoMap  = Record<Todo["id"], Todo>;       // 索引表

八、satisfies:验形状,不丢推断

TS 4.9 引入 satisfies,一次满足三个诉求:想检查形状、不想丢字面量推断、不想像 as 那样骗编译器。

const themes = {
  dark:  { bg: "#111", fg: "#eee" },
  light: { bg: "#eee", fg: "#111" },
} satisfies Record<string, { bg: string; fg: string }>;

themes.dark.bg;  // 类型仍是 "#111"(字面量保住了),且拼错键会报错
// 对比:去掉 satisfies——没有任何检查
// 对比:写成 as Record<...>——有检查,但推断全部放宽成 string

最佳实践

  • 边界手写标注(函数参数、导出 API),内部交给推断;标注密度过高和过低都是坏味道。
  • 互斥状态用判别联合建模,配 assertNever 穷尽检查;两个以上布尔并存时先想想是不是状态机写歪了。
  • any 只许出现在迁移期和类型定义文件;边界数据一律 unknown 起步,校验后才使用。
  • 状态建模先于函数编写:类型写对了,函数体常常只剩一种写法。

常见问题

什么时候允许 as? 基本两个场景:测试里构造部分对象、双重断言桥接动态加载的模块。能用收窄或校验解决的都不该用 as——as 是”我比编译器懂”,每次写都要能说出为什么。

开了 strict 之后报错暴涨,要不要先关掉? 不要。报错暴涨说明代码里本来就有这么多隐式约定没被写下来,strict 只是让它们现形。正确动作是逐条处理:该收窄的收窄、该校验的校验、确实无害的用显式分支表达意图。关掉 strict 等于把体检报告烧了——病灶还在。

练习与实践

  • 写 parsePriority(raw: unknown): “high” | “low”:非法输入返回 “low” 而不是抛异常,想一想 CLI 场景为什么要容错。
  • 把 isLoading / hasError / hasData 三布尔的渲染逻辑重构成判别联合,用 assertNever 保证穷尽。
  • 给 Todo 写 Partial<Pick> 并解释每一层分别约束什么。

延伸阅读

版本说明:本文语法适用于 TypeScript 5.x 及之后版本(satisfies 需 4.9+)。截至写作(2025-02-15),npm latest 为 TypeScript 5.7.x(2024-11 发布);语言语法跨版本稳定,具体行为以官方文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇