Skip to content

2.1 类型入门:基本类型与对象

本章解决什么问题:1.1 说过 TypeScript 的核心能力是「编译期类型检查」,但没有回答:类型到底是什么?它具体能拦住哪些错误、拦不住哪些?本章从一个真实的拼写错误 bug 出发,把最常用的类型语法过一遍,并建立一个贯穿全书的关键世界观——类型只存在于编译期,运行时的数据它管不着

前置知识1.1 JavaScript、TypeScript 与 Node.js(类型会被抹掉这一事实)、1.2 运行第一个 TypeScript 程序tsxtsc --noEmit 的用法)。

学习目标:读完本章后你能

  • 用「值的集合」解释类型是什么,说出类型检查在什么时机、拦住哪类错误;
  • 读写 string、number、boolean、null、undefined、数组、元组与对象类型字面量;
  • ? 声明可选属性、用 readonly 禁止修改,并知道有类型推断在,不必处处写类型;
  • 说清 any 与 unknown 的区别,知道两者之间默认选谁;
  • 解释为什么 JSON.parse 的结果类型系统管不了,以及这与 Pi 校验工具参数的关系。

建立直觉:类型是给值划定的集合

它是什么:一个类型就是「一组允许的值,加上这组值身上允许的操作」。number 是所有数字的集合,数字身上允许加减乘除;string 是所有字符串的集合,字符串身上允许取长度、拼接。当你写下 a: number,你是在向编译器承诺:a 里只会装这个集合里的值——编译器则反过来替你盯着:谁往 a 里塞了集合之外的东西、谁对 a 做了数字不支持的操作,立刻报错。

为什么需要它:1.1 已经演示过「数字 + 字符串」悄悄变成拼接的坑。这里再看一个更隐蔽、也更常见的坑——拼写错误。把下面两行存成 greet.js(注意这是纯 JavaScript):

js
const user = { name: "小李", email: "li@example.com" };
console.log("欢迎回来," + user.nmae);

nmaename 的手误。用 node greet.js 运行,真实输出是:

欢迎回来,undefined

没有类型检查会怎样:JavaScript 规定,访问对象上不存在的属性不报错,而是得到 undefined(一个表示「没有值」的特殊值,下一节细讲)。于是这个手误安静地穿过所有代码,最后以「欢迎回来,undefined」的样子出现在用户面前。更糟的情况是这个 undefined 继续参与运算、被存进文件,等问题暴露时,离手误那一行可能已隔了几十个函数。

最小例子:同样的代码改名为 greet.ts,用 1.1 介绍过的方式检查(--noEmit 表示只检查、不产出文件):

sh
tsc --noEmit greet.ts

真实输出:

greet.ts(2,28): error TS2339: Property 'nmae' does not exist on type '{ name: string; email: string; }'.

第 2 行第 28 列:类型 { name: string; email: string; } 上不存在属性 nmae。程序压根没有运行,错误就被指名道姓地揪了出来。注意一个细节:我们一个类型标注都没写——user 的类型是 TypeScript 自己从字面量(字面量指直接写在代码里的值本身,比如 "小李"42)推断出来的。这叫类型推断,本章后面专门讲。

图加载中…

图 2.1-1 同一个拼写错误的两条命运
阅读顺序:从上往下,左右两条分支对比。左边是 JavaScript 的路径——错误存活并远离源头;右边是 TypeScript 的路径——错误止步于编译期。本章其余内容都在回答一个问题:右边这道关卡是怎么描述「合法值长什么样」的。

基本类型:五种最常用的原料

标注类型的语法统一是「名字冒号类型」。三种天天见面的基本类型:

ts
const modelName: string = "pi-mini"; // 字符串
const maxTokens: number = 4096;      // 数字:整数、小数都是 number
const streaming: boolean = true;     // 布尔:只有 true / false 两个值

JavaScript 不区分整数和浮点数,所以只有一个 number

再是两种「没有值」——它们既是值,也各自是只含这一个值的类型:

  • undefined:「从来没被赋过值」或「这里不存在」。访问不存在的属性、忘了赋值的变量,得到的都是它——上一节的 bug 里出现的正是它。
  • null:「有意置空」。它表示某人主动写下了「这里没有东西」。

单独把变量标成 nullundefined 类型没什么用(那样它永远只能装这一个值)。它们几乎总是和别的类型搭配出现,写作 string | null,竖线读作「或」——「字符串,或者 null」。这种组合叫联合类型,是 2.3 的主角,本章先混个脸熟即可。

🌱 初学者提示typeof null 是 object?
运行时可以用 `typeof x` 查看一个值的类别,例如 `typeof "hi"` 得到 `"string"`。但 `typeof null` 得到的是 `"object"`——这是 JavaScript 1995 年第一版实现留下的 bug,因为兼容问题永远改不掉了。判断 null 请直接写 `x === null`。另外 JavaScript 还有 bigint、symbol 等基本类型,Pi 源码里极少出现,本书不展开。

数组与元组

装多个值时用数组,元素类型写在 [] 前面:

ts
const tags: string[] = ["typescript", "类型", "pi"];

string[] 读作「字符串数组」:长度随意,但每个元素必须是字符串。往里 push 一个数字是编译错误。

元组(Tuple)是数组的收紧版:长度固定、每个位置的类型也固定

ts
const entry: [string, number] = ["input", 120];

第 0 位必须是字符串、第 1 位必须是数字,写反了、多一个少一个都报错。元组适合「几个值天生成对出现」的场景;不过实践中位置不如名字直观,所以更常见的选择是下一节的对象类型——每个值都有名字。

对象类型:描述对象的「形状」

对象是 JavaScript 组织数据的主要方式,对象类型就是描述「这个对象有哪些键、每个键装什么类型」——常被称作对象的形状(shape)。语法是把键值对写进花括号,这种写法叫对象类型字面量

ts
const user: { name: string; email: string } = {
  name: "小李",
  email: "li@example.com",
};

形状写在类型里之后,本章开头那类拼写错误就无处可藏:读写 user 的任何一个不存在的键都是编译错误。

到处重复这串花括号很啰嗦,可以用 type 关键字给它起个名字,之后用名字代替:

ts
type Usage = {
  readonly model: string; // readonly:创建之后不允许再改
  input: number;
  output: number;
  reasoning?: number;     // ?:这个键可以不存在
};

type 起名与 interface 的完整对比是 2.2 的内容,这里先会用即可。)这个例子顺便引出了对象类型的两个修饰符:

可选属性 ?reasoning?: number 表示这个键可以不存在;一旦存在,必须是数字。代价是:使用它之前必须先处理「不存在」的情况,否则编译器不放行:

ts
function totalTokens(u: Usage): number {
  // reasoning 可能不存在,先兜底成 0
  const reasoning = u.reasoning === undefined ? 0 : u.reasoning;
  return u.input + u.output + reasoning;
}

只读属性 readonly:对象创建之后,这个键不允许再被赋值。试图写 usage.model = "pi-max" 会得到编译错误 Cannot assign to 'model' because it is a read-only property.。它把「这份数据不该被改」从口头约定变成编译器强制——在 Pi 这类到处传递消息对象的程序里,这能防住大量「不知道被谁改了一笔」的疑案。

类型推断:不必处处写类型

看了这么多标注,你可能担心 TypeScript 就是「每行都得写类型」的体力活。恰恰相反——大多数时候不用写。TypeScript 会从初始值自动推断类型:

ts
const modelName = "pi-mini"; // 推断为 string
const maxTokens = 4096;      // 推断为 number
const streaming = true;      // 推断为 boolean

这三个变量的类型和上一节手写标注的版本完全相同,检查力度也完全相同——本章开头 greet.ts 一个标注没写照样抓住拼写错误,靠的就是推断。实践中的常见取舍是:

  • 该写:函数的参数和返回值(推断不了参数;写明返回值等于给函数立契约)、被多处共享的对象形状(如上面的 Usage);
  • 可省:有初始值的局部变量——写了反而是噪音。

从源码结构看,Pi 的代码正是这个风格:类型集中写在各个 types.ts 与函数签名上,函数体内部的局部变量大多依赖推断。

any 与 unknown:两种「不知道是什么」

有时你确实说不出一个值的类型——最典型的就是解析外部数据。TypeScript 给了两个选项,态度截然相反:

any:关掉检查的后门。标成 any 的值可以做任何操作、赋给任何变量,编译器全部放行。它不是「任意类型」,而是「请别检查我」。危险之处在于会扩散:一个 any 值参与的运算、赋值往往继续得到 any,用得多了,类型检查形同虚设——本章开头那类 bug 就会重新溜进来。

unknown:诚实的「不知道」。标成 unknown 的值恰好相反:在你验明它的真实身份之前,什么操作都不允许

ts
const data: unknown = JSON.parse(someText);
// data.output;            // 编译错误:unknown 上不允许任何属性访问
if (typeof data === "object" && data !== null) {
  // 这个分支里,编译器知道 data 至少是个对象了
}

像这样用检查语句一步步说服编译器的过程叫类型收窄(Narrowing),系统的收窄手段在 2.4 展开。本章记住结论即可:拿不准类型时默认用 unknown,把 any 当成万不得已的逃生门

编译期与运行时:类型会被擦除

现在到了本章最重要的一节。1.1 讲过:.ts 变成可执行的 JavaScript 时,所有类型标注都会被抹掉。这句话的完整含义值得掰开:

  • 编译期(类型的世界):tsc 拿着你的标注检查每一行代码。这个世界里只有源码,没有真实数据。
  • 运行时(值的世界):Node.js 执行的是抹掉类型后的 JavaScript。这个世界里只有值,类型已经不存在了——没有任何检查在运行。

于是出现一个关键推论:凡是运行时才出现的数据,类型检查一概管不了。最典型的入口就是 JSON.parse——它在运行时把一段 JSON 文本(JSON 是用文本表示对象的通用格式,1.3 的 package.json 用的就是它)解析成对象,而这段文本长什么样,编译器在编译期根本无从知道,所以它的返回类型只能是 any。看这个例子(完整可运行版本在本章实验里):

ts
const badJson = '{"model":"pi-mini","input":120,"outptu":45}'; // 键名 output 拼错了

const bad = JSON.parse(badJson) as Usage;
console.log(totalTokens(bad));

as 叫类型断言,读作「你就当它是 Usage」——它只是让编译器按你说的当真,不做任何真实检查。这段代码顺利通过编译,运行时的真实输出是:

NaN

bad.output 不存在,取到 undefined数字 + undefined 算出 NaN(Not a Number,「不是个数」的占位值),程序不报错,带着 NaN 继续跑——和本章开头的 JavaScript 拼写 bug 一模一样。类型系统没失灵,它只是不在场:拼写错误发生在数据里,而数据是运行时才到场的。

图加载中…

图 2.1-2 类型的世界与值的世界
阅读顺序:从左到右。类型检查只发生在左边的方框里;右下角的「外部数据」从来没有经过左边那道关卡——这就是 JSON.parse 返回 any 的根本原因。要在右边的世界里拦住坏数据,只能靠运行时校验,也就是真正执行的检查代码。

这个「编译期管不了运行时数据」的缺口,正是后面读 Pi 源码时反复出现的主题:Agent 的工具参数是模型在运行时生成的 JSON,类型标注保护不了它。从源码结构看,Pi 的每个内置工具都引入了一个叫 typebox 的库,用它声明参数结构并在运行时逐字段校验模型发来的参数——相当于把本章的类型检查「搬到」运行时再做一遍。细节在6.4 工具系统:定义、校验与执行展开,眼下你只需记住这个缺口的存在。

⚠️ 常见误解以为写了 as 就安全了
`as` 断言经常被当成「类型转换」,其实它什么都不转换、什么都不检查,只是压住编译器的报错。对外部数据用 as,等于亲手把类型系统请出房间。正确的姿势是 unknown 加运行时检查(或像 Pi 那样用校验库);as 只该用在你确实比编译器知道得多的少数场合。

Pi 中哪里用到了它

本章的语法在 Pi 源码里俯拾即是。看 packages/ai 包(Pi 里负责和模型 API 打交道的 package)的类型定义文件中的两个例子。

第一个是描述图片内容的类型,三个键、形状一目了然:

ts
export interface ImageContent {
	type: "image";
	data: string; // base64 encoded image data
	mimeType: string; // e.g., "image/jpeg", "image/png"
}
packages/ai/src/types.ts · ImageContent
earendil-works/pi@c13ffe1第 354–358 行在 GitHub 查看 ↗
一条图片消息内容的形状:data 装图片数据的文本编码,mimeType 说明图片格式。

interface 是给对象类型起名的另一种方式(与 type 的异同正是下一章的内容),花括号里的部分就是本章讲的对象类型字面量。type: "image" 这行有点特别:它的类型不是 string,而是「只能等于字符串 "image"」的字面量类型——2.3 会讲它为什么关键。

第二个例子是 Usage——记录一次模型调用消耗了多少 token、花了多少钱。本章实验里的同名类型就是它的简化版:

ts
export interface Usage {
	input: number;
	output: number;
	cacheRead: number;
	cacheWrite: number;
	// …(省略:一行文档注释)
	cacheWrite1h?: number;
	// …(省略:五行文档注释)
	reasoning?: number;
	totalTokens: number;
	cost: {
		input: number;
		output: number;
		cacheRead: number;
		cacheWrite: number;
		total: number;
	};
}
earendil-works/pi@c13ffe1第 368–389 行在 GitHub 查看 ↗
一次模型调用的 token 用量统计。注意两个可选属性:源码注释说明 cacheWrite1h 只有 Anthropic 这一家模型服务会报告,reasoning 只有部分服务会报告——「有的数据源有这个字段、有的没有」正是 ? 的标准用武之地。

这个 20 行的类型把本章多个知识点串在了一起:全是 number 的基本类型;cacheWrite1h?reasoning? 两个可选属性——可选的原因写在源码注释里,不是每家模型服务都会报告这两项数据;还有一个嵌套的对象类型字面量 cost,把各项费用聚在一起。在同一个文件里,模型每回复一条消息,附带的用量统计字段用的就是这个 Usage 类型——第 3.2 章讲 token 计费、6.1 章读这个 package 时都会再遇到它。

实践任务

🛠 实践任务用类型接住三个 buglabs/typescript-basics/04-types

目标:跑通本章全部示例,亲眼看到「tsx 不检查类型」「类型擦除后坏数据畅通无阻」,并修复一个故意写坏的文件。实验目录:labs/typescript-basics/04-types

步骤

  1. 进入实验目录,安装依赖并运行:

    sh
    cd labs/typescript-basics/04-types
    npm install
    npm start
  2. 对照输出与目录里的 expected-output.txt,重点看第 4 段的 NaN 和第 5 段 any 与 unknown 的对比;

  3. 运行类型检查 npm run check,阅读 3 条报错,按 src/broken.ts 注释里的意图逐个修复,直到 npm run check 没有任何输出;

  4. 加练:取消 src/main.ts 里两行「错误示范」注释,观察 readonly 与拼写错误的报错样子,再注释回去。

预期现象npm start 全程无报错,第 4 段输出「键名拼错的 JSON,总 token 数:NaN」;npm run check 首次运行恰好报 3 个错,全部指向 src/broken.ts

如何判断成功:修复后 npm run check 无输出、npm start 输出与 expected-output.txt 一致;并能回答——为什么 npm start 从头到尾都成功,npm run check 却报错?(提示:tsx 只做转换,不做检查。)

常见错误tsx: command not found 说明还没在实验目录里 npm install;修复 broken.ts 时把整行删掉可以让报错消失,但请按注释要求「改对」而不是删掉——练的就是读懂报错。

对应源码位置:实验里的 Usage 类型是 packages/ai/src/types.tsUsage 的简化版(见上一节 SourceRef)。

本章小结

  • 类型是「一组允许的值加允许的操作」;类型检查在编译期把拼写错误、传错类型这类 bug 拦在运行之前。
  • 基本类型 string、number、boolean,加上两种「没有值」:undefined(不存在、没赋值)与 null(有意置空)。
  • string[] 是长度不限的同类元素数组;[string, number] 是长度和每个位置类型都固定的元组。
  • 对象类型字面量描述对象的形状;? 声明可有可无的键(用前必须处理 undefined),readonly 禁止创建后修改。
  • 类型推断让有初始值的变量不必写标注;标注重点写在函数签名和共享的对象形状上。
  • any 是「请别检查我」,会扩散;unknown 是「先检查再使用」——默认选 unknown。
  • 类型在编译产物里被擦除:运行时没有类型检查,JSON.parse 等运行时数据入口返回 any,坏数据只能靠运行时校验拦截——Pi 用 typebox 校验工具参数正是为此。

关键术语:类型(Type)、类型检查、基本类型、元组(Tuple)、对象类型字面量、形状(Shape)、可选属性 ?readonly、类型推断(Type Inference)、字面量(Literal)、any、unknown、类型收窄(Narrowing)、类型断言 as、类型擦除、NaN

关键源码索引packages/ai/src/types.tsImageContent(第 354–358 行)与 Usage(第 368–389 行)

自测问题

  1. user.nmae 这个手误,在纯 JavaScript 里运行时会发生什么?TypeScript 又是在什么时机、凭什么信息抓住它的?
  2. reasoning?: numberreasoning: number | undefined 都表示「可能没有」,? 版本多允许了什么?(提示:键本身可以怎样?)
  3. 为什么 JSON.parse 的返回类型只能是 any?const u = JSON.parse(s) as Usage 之后访问 u.output 一定安全吗?
  4. 你从外部拿到一个类型说不准的值,any 和 unknown 应该先考虑哪个?为什么?

下一章预告2.2 interface、type 与函数类型——本章已经见过 type 起名和源码里的 interface,下一章正式对比两者,并给「函数」这种 Pi 里最重要的值也标上类型。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)