TS·01 值、类型、函数:声明、标注与推断

这是「读懂 pi 的 TypeScript」系列的第 1 篇正文。上一篇(入门与心智模型)解释了 TS = JS + 编译期类型检查;这一篇打地基:如何声明变量、给它们贴类型、写带类型的函数。这三样是你读 pi 任何一行代码都绕不开的基础。

我们不空讲语法。三章分别对应 pi 仓库里三个真实的小文件:一个计算器工具 calculate、一个报时工具 getCurrentTime、一个事件流类 EventStream 的 push 方法。读完这篇,你就能逐字读懂这三个函数的签名——它们恰好覆盖了变量、参数、返回值、可选参数、数组、对象字面量这些最核心的形状。后面几篇讲的接口、联合、泛型,都建在这一篇之上。

1. 变量与基本类型:标注 vs 推断

1.1 直觉

Python 里变量一律 x = 1,重新赋值、改类型都随意。TS 有两个声明关键字:const(常量,不可重新绑定)和 let(变量,可重新赋值)。这个区分 Python 没有——最接近的是 Final 标注,但那只是提示,而 TS 的 const 是编译器强制的。

类型方面,TS 有 string、number(不分 int/float,统一一个)、boolean 等基本类型。你可以显式标注 const n: number = 1,也可以不写、让编译器从右侧的值自动推断。两者产生的类型完全一样。什么时候写、什么时候省,是本章的重点。这里要建立的一个关键认知是:TS 里「变量有没有写类型」不影响它「有没有类型」——不写只是把活儿交给编译器,变量始终是静态类型的。这跟 Python「不写类型就是动态」的直觉正好相反,是从 Python 迁移过来最需要扭转的一点。

1.2 最小 demo

// 教学示例 — 非生产代码
const name: string = "pi";      // 显式标注为 string
let count = 0;                   // 推断为 number,因为右侧是 0
count = count + 1;              // let 可以重新赋值,OK
// name = "other";             // 报错:const 不可重新绑定

const enabled = true;           // 推断为 boolean
const nothing = null;           // 推断为 null

// 函数返回值也能推断:add 的返回类型是 number
function add(a: number, b: number) {
  return a + b;
}
const sum = add(1, 2);          // sum 被推断为 number

注意 count 和 enabled 没写类型,但它们不是 Python 那种动态变量——编译器已经把它们钉死成 number 和 boolean 了。给 count 赋一个字符串会立刻报错。

1.3 正式化

声明的通用形式是:const | let 名字 [: 类型] = 值。

标注 vs 推断的取舍:局部变量、有初始值时,省略标注,让推断来做——写 const n = 0 而不是 const n: number = 0,更简洁且不会撒谎。需要显式标注的场景主要是:函数参数(推断不了,必须写)、想约束一个比推断结果更窄或更宽的类型、以及导出的 API 边界(下一章的返回值标注)。

基本类型名都是小写:string、number、boolean、null、undefined。写成大写的 String、Number 是另一回事(包装对象),几乎永远不要用。

顺带说一下 null 和 undefined 这对——Python 只有一个 None,TS 却有两个「空」。粗略地说,undefined 是「没赋值 / 没这个东西」(变量声明了没给值、可选参数没传、对象没这个字段),null 是「主动置空」。pi 的代码里两者都用,但更偏好 undefined;你现在只要知道它们都表示「空」、且各自是独立的类型即可,细节留到窄化与类型守卫那篇处理「值可能为空」的判断。

1.4 代码引用

看 pi 里一个真实的工具函数——把数学表达式求值的计算器:

pi/packages/agent/test/utils/calculate.ts:L9-L16 — calculate 工具:string 参数 + 具名返回类型 + catch 里的类型标注

export function calculate(expression: string): CalculateResult {
	try {
		const result = new Function(`return ${expression}`)();
		return { content: [{ type: "text", text: `${expression} = ${result}` }], details: undefined };
	} catch (e: any) {
		throw new Error(e.message || String(e));
	}
}

对照本章讲的点:

: CalculateResult 是返回类型标注,属于下一章的内容,这里先放着。

1.5 洞察

2. 函数:参数与返回标注、可选/默认参数、箭头函数

2.1 直觉

函数是 pi 的主体。TS 函数和 Python 的 def f(x: int) -> str: 心智几乎一一对应:参数可标类型,返回值可标类型。差别在语法细节和两种写法:传统的具名函数 function f() {},以及箭头函数 const f = () => {}(类似 Python 的 lambda,但可以有完整函数体)。

可选参数用 param?: 表示「可以不传」,对应 Python 的 x=None 或 Optional;默认参数 param = 值 对应 Python 的默认值。两者是不同的东西,本章讲清楚。

Python 对照:Python 里 def f(x: int = 0) 用「有默认值」同时表达了「可以不传」;TS 把这两件事拆成了两个语法——x?: number(可不传,不传就是 undefined)和 x = 0(不传就用 0)。迁移时别把 ? 和 = 0 当同义词。

2.2 最小 demo

// 教学示例 — 非生产代码
// 具名函数:参数标类型,返回标 string
function greet(who: string): string {
  return `hello, ${who}`;
}

// 箭头函数:同样的签名,存进 const
const greet2 = (who: string): string => `hello, ${who}`;

// 可选参数 suffix?:调用时可省略,类型是 string | undefined
function label(name: string, suffix?: string): string {
  return suffix ? `${name}.${suffix}` : name;
}
label("pi");              // OK,suffix 为 undefined
label("pi", "ts");       // OK

// 默认参数:不传时用默认值,count 类型仍是 number
function repeat(text: string, count = 2): string {
  return text.repeat(count);
}
repeat("ab");             // "abab"

greet 和 greet2 完全等价,只是写法不同。箭头函数省掉了 function 关键字,=> 右边如果是单个表达式还能省掉 { return ... }——上面 greet2 就直接返回模板字符串。

2.3 正式化

一个坑:可选参数 suffix?: string 和「参数类型为 string | undefined」不完全一样——前者调用时可以不写,后者必须传(哪怕传 undefined)。

2.4 代码引用

pi 里的报时工具用到了可选参数:

pi/packages/agent/test/utils/get-current-time.ts:L6-L22 — getCurrentTime:可选参数 timezone? + async 返回

export async function getCurrentTime(timezone?: string): Promise<GetCurrentTimeResult> {
	const date = new Date();
	if (timezone) {
		try {
			const timeStr = date.toLocaleString("en-US", {
				timeZone: timezone,
				dateStyle: "full",
				timeStyle: "long",
			});
			return {
				content: [{ type: "text", text: timeStr }],
				details: { utcTimestamp: date.getTime() },
			};
		} catch (_e) {
			throw new Error(`Invalid timezone: ${timezone}. Current UTC time: ${date.toISOString()}`);
		}
	}
	// ...
}

对照本章讲的点:

2.5 洞察

3. 数组、对象字面量与内联类型、void

3.1 直觉

前两章的值都是标量。真实代码里数据是数组和对象。TS 的数组类型写作 T[](如 string[] 是字符串数组),对应 Python 的 list[str]。对象的类型可以内联写在原地:{ type: string; text: string } 描述「有 type 和 text 两个字符串字段的对象」,类似 Python 的 TypedDict,但可以匿名、就地使用。

还有个特殊返回类型 void:表示「这个函数不返回有意义的值」,对应 Python 里返回 None(或标注 -> None)的函数。真实代码里大量函数属于这一类——它们做的是副作用:打印日志、把数据推进队列、触发一个事件,而不是算一个值返回给你。给这类函数标 void,是在告诉调用方「别指望我的返回值」。

3.2 最小 demo

// 教学示例 — 非生产代码
// 数组类型:T[]
const names: string[] = ["a", "b"];
const nums = [1, 2, 3];          // 推断为 number[]

// 内联对象类型:字段就地声明
const point: { x: number; y: number } = { x: 1, y: 2 };

// 数组套对象:每个元素形如 { type, text }
const items: { type: string; text: string }[] = [
  { type: "text", text: "hello" },
];

// 返回 void 的函数:只做事,不产出值
function log(msg: string): void {
  console.log(msg);
  // 没有 return,或 return; 都行
}

nums 没写类型,推断成 number[]。items 的类型 { type: string; text: string }[] 读法是「一个数组,元素都是有 type 和 text 字段的对象」——注意 [] 贴在整个对象类型后面。

3.3 正式化

3.4 代码引用

pi 的 ai 包里有个事件流类 EventStream,它的 push 方法就是典型的 : void:

pi/packages/ai/src/utils/event-stream.ts:L21-L36 — push 方法:返回 void 的副作用函数

push(event: T): void {
	if (this.done) return;

	if (this.isComplete(event)) {
		this.done = true;
		this.resolveFinalResult(this.extractResult(event));
	}

	// Deliver to waiting consumer or queue it
	const waiter = this.waiting.shift();
	if (waiter) {
		waiter({ value: event, done: false });
	} else {
		this.queue.push(event);
	}
}

对照本章讲的点:

再回头看第一章 calculate 的返回值,那里有数组套对象字面量的真实例子:

pi/packages/agent/test/utils/calculate.ts:L4-L6 — CalculateResult:数组 + 内联对象类型

export interface CalculateResult extends AgentToolResult<undefined> {
	content: Array<{ type: "text"; text: string }>;
	details: undefined;
}

content: Array<{ type: "text"; text: string }> —— 一个数组,元素是 { type: "text"; text: string }。注意 type: "text" 不是 type: string,而是字符串字面量类型「只能是 "text" 这个值」(见联合与可辨识联合)。它对应的运行时数据,就是 calculate 里返回的 content: [{ type: "text", text: ... }]——类型和值花括号长得一样,一个在 interface 里、一个在 return 里。

3.5 洞察

讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。