本节目标:理解「函数签名」这个概念的准确含义,掌握参数注解、返回值注解、可选参数、默认值参数、剩余参数与参数解构的写法;能够解释可选参数与默认值参数在类型层面的区别,并读懂严格模式下常见的函数相关报错。学完本节,你写的每一个函数都会先有一份「契约」,再有一份「实现」。
4.1 签名、可选参数与默认值
在上一章 3.3 any·unknown·never·void 与类型断言
里,我们已经见过函数返回 void 与 never 的样子。但那时我们关注的是「返回值类型」,没有认真看函数本身的结构。这一节把镜头转向函数的正面:签名(signature)。
什么是函数签名
函数签名是对一个函数「外部可见部分」的完整描述,它由三块组成:
- 函数名(在作为值传递时可以是匿名的);
- 参数列表,每个参数的名字、类型、是否可选;
- 返回值类型。
一个函数签名回答的问题是:调用方需要提供什么,能拿回什么。函数体内部怎么实现,不属于签名的一部分。
// 这个函数的签名是: (id: number) => string
function getUserName(id: number): string {
return `user-${id}`;
}
注意 (id: number) => string 这种写法——它既是「函数类型的字面量写法」,也是我们讨论签名时的通用记号。理解这一点很关键:在 TypeScript 里,类型和值是两套平行的语法,函数也不例外。
三种声明函数的方式
TypeScript 里定义函数至少有三种写法,它们的类型行为略有差异:
// 1. 函数声明(function declaration)——会被提升
function add(a: number, b: number): number {
return a + b;
}
// 2. 函数表达式(function expression)——不会提升
const sub = function (a: number, b: number): number {
return a - b;
};
// 3. 箭头函数(arrow function)——不会提升,this 行为不同
const mul = (a: number, b: number): number => a * b;
三者中,函数声明的返回值类型通常可以省略,因为 TypeScript 能从 return 语句推断出来。但本书建议:公开 API(导出的函数、被多处调用的工具函数)显式写返回值类型。原因有两个:
- 推断出的类型会随实现变化,一处小改动可能悄悄改变对外契约;
- 显式标注能在实现里拦住「不小心返回了错误类型」的情况。
// 显式标注后,实现写错会被立刻发现
function toFixed2(value: number): string {
// Error: Type 'number' is not assignable to type 'string'.
return value * 2;
}
参数注解与上下文推断
参数的类型注解是必须的(在 noImplicitAny 打开时),因为编译器无法从函数体反推调用方会传什么:
// Error: Parameter 'name' implicitly has an 'any' type.
function greet(name) {
return `Hello, ${name}`;
}
// 正确写法
function greet2(name: string): string {
return `Hello, ${name}`;
}
但有一类例外:当函数是作为回调传给已知类型的形参时,参数类型可以被「上下文推断」出来,此时可以不写注解:
const nums = [1, 2, 3];
// 这里的 n 被推断为 number,不必手写
nums.map((n) => n * 2); // [2, 4, 6]
// 手写也行,但属于冗余
nums.map((n: number) => n * 2);
这种机制叫上下文类型(contextual typing),是 TypeScript 体验最好的特性之一。判断标准很简单:如果编译器能从上文推出类型,就不写;推不出来,就必须写。
可选参数:用 ? 标记
默认情况下,TypeScript 要求调用方传满所有参数。如果某个参数可以省略,就在参数名后加 ?:
function createUser(name: string, age?: number): string {
return age === undefined ? name : `${name} (${age})`;
}
createUser("Alice"); // "Alice"
createUser("Bob", 30); // "Bob (30)"
关键点:可选参数的类型会自动加上 undefined。上面 age 的实际类型是 number | undefined。所以在函数体里使用它之前,必须先做检查——这正好接上了我们在 3.3 节讲过的收窄思路:
function createUser(name: string, age?: number): string {
if (age !== undefined) {
return `${name} (${age})`;
}
return name;
}
可选参数有两条硬性规则:
- 必须放在必选参数之后。
function f(a?: number, b: string)是语法错误,因为调用f(undefined, "x")虽然能写,但语义混乱。 - 数量上可以有多个,但多个可选参数会让调用方难以判断「我省略的到底是哪一个」。
默认值参数:= value
默认值参数的语法更自然,也更常用:
function greet(name: string, greeting = "Hello"): string {
return `${greeting}, ${name}`;
}
greet("Alice"); // "Hello, Alice"
greet("Bob", "Hi"); // "Hi, Bob"
默认值参数和可选参数看起来效果相似,但在类型层面有两个重要差异:
| 对比项 | 可选参数 age?: number | 默认值参数 age = 18 |
|---|---|---|
| 参数类型 | number | undefined | number |
| 函数体内部 | 必须判 undefined | 可直接当 number 用 |
显式传 undefined | 得到 undefined | 触发默认值 |
| 类型推断 | 需要手写类型 | 可从默认值推断 |
| 能否在必选参数之前 | 不能 | 可以(但会强制显式传 undefined) |
第二点最容易被忽略。看这个例子:
function log(level = "info", message: string): void {
console.log(`[${level}] ${message}`);
}
// 因为 level 有默认值,它不被视为「可选」,位置仍在最前
log("warn", "disk almost full"); // [warn] disk almost full
log(undefined, "ok"); // [info] ok
所以默认值参数出现在必选参数之前是合法的,但调用方必须显式写 undefined 才能跳到后面的参数。工程实践中,除非有强烈理由,默认值参数也应该放在参数表末尾。
再看一个「默认值可以是表达式」的例子,理解默认值的求值时机:
function now(label: string, at: Date = new Date()): string {
return `${label} @ ${at.toISOString()}`;
}
// 每次调用都会重新求值,不会共享同一个 Date 实例
console.log(now("first"));
console.log(now("second"));
默认值表达式在每次调用、且该参数为 undefined 时才求值。这一点和 Python 的「可变默认值陷阱」完全不同,TypeScript/JavaScript 里不存在那个坑。
默认值与类型推断的配合
当默认值参数没有显式注解时,TypeScript 会用默认值的类型作为参数类型:
function repeat(text: string, times = 3) {
return text.repeat(times);
}
// times 被推断为 number,等价于 times: number = 3
repeat("ab", 2); // "abab"
repeat("ab", "2"); // Error: Argument of type 'string' is not assignable to 'number'.
但推断出的类型可能比你想的更窄。看这个常见陷阱:
// 推断出 times: number —— 没问题
function repeatOk(text: string, times = 3) {
return text.repeat(times);
}
// 推断出 config: { retries: number; verbose: boolean } —— 太窄了
// 调用方无法只传 { retries: 5 },因为推断出的形状要求两个字段都齐全
function request(url: string, config = { retries: 3, verbose: false }) {}
对象字面量作为默认值时,推断出的是具体形状而不是宽泛类型。解决办法是显式标注:
interface RequestConfig {
retries?: number;
verbose?: boolean;
}
function request(url: string, config: RequestConfig = {}): void {
const { retries = 3, verbose = false } = config;
console.log(url, retries, verbose);
}
request("/api/users", { retries: 5 }); // OK
注意 config: RequestConfig = {} 里用空对象当默认值——此时 RequestConfig 的字段必须是可选的,否则 {} 不合法。
剩余参数(rest parameters)
当参数个数不确定时,用 ... 收集剩余参数。在 TypeScript 里它的类型是一个数组:
function sum(...nums: number[]): number {
return nums.reduce((acc, n) => acc + n, 0);
}
sum(); // 0
sum(1, 2, 3); // 6
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.
sum(1, "2");
如果元素类型不止一种,就用元组类型精确描述——这是 TypeScript 比 JavaScript 强的地方:
// 第一个必须是 string,后面可以跟任意个 number
function tag(label: string, ...values: number[]): string {
return `${label}: ${values.join(", ")}`;
}
// 用元组描述「恰好两个」的定长参数
function point(...coords: [number, number]): { x: number; y: number } {
return { x: coords[0], y: coords[1] };
}
point(1, 2); // { x: 1, y: 2 }
// Error: Expected 2 arguments, but got 3.
point(1, 2, 3);
...coords: [number, number] 这种写法在 React 的 useState 类型、事件库的参数转发里非常常见,值得记住。
参数解构与默认值的组合
解构参数能让签名自解释,但也带来一个易错点:
interface Options {
host: string;
port?: number;
secure?: boolean;
}
// 解构 + 每项默认值:清晰且类型安全
function connect({ host, port = 443, secure = true }: Options): string {
return `${secure ? "https" : "http"}://${host}:${port}`;
}
connect({ host: "example.com" }); // "https://example.com:443"
注意:这里 port = 443 的默认值只在 port 为 undefined 时生效,因此 Options.port 必须声明为可选(port?: number),否则调用方必须显式传值。如果你希望整个对象参数都可省略,还要给整个解构模式一个默认值:
function connect2({ host = "localhost", port = 443 }: Options = {}): string {
return `http://${host}:${port}`;
}
connect2(); // "http://localhost:443"
这里的 Options = {} 要求 Options 的所有字段都是可选的(或者对象本身可空)。这是「全可选配置对象」的经典写法。
常见报错与排查
下面这些错误信息你迟早会遇到,提前认识它们能省下大量时间:
function format(value: string, precision?: number): string {
return precision !== undefined ? value.repeat(precision) : value;
}
// 1) 传错类型
format(123);
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.
// 2) 可选参数位置错误
// Error: A required parameter cannot follow an optional parameter.
function bad(a?: number, b: string) {}
还有一类和 strictNullChecks 相关:
function len(text?: string): number {
// Error: 'text' is possibly 'undefined'.
return text.length;
}
// 修复一:判空
function len2(text?: string): number {
return text === undefined ? 0 : text.length;
}
// 修复二:默认值(把「可选」改成「有默认值」,类型立刻收窄)
function len3(text = ""): number {
return text.length;
}
经验法则:如果一个参数「逻辑上总该有值,只是调用方可以偷懒」,用默认值;如果它「真的可以不存在」,用可选参数。前者能让函数体少一半判空代码。
与函数类型别名的衔接
签名不仅出现在函数声明上,也可以被抽出来单独命名:
type Formatter = (value: string, precision?: number) => string;
const upper: Formatter = (value) => value.toUpperCase();
// 上下文推断:这里 value 自动是 string,不必手写
const padded: Formatter = (value, precision = 2) => value.padStart(precision, "0");
把签名抽成类型别名,是实现「依赖注入」「策略模式」的第一步。关于如何用函数类型组织更大的结构,我们会在 5.1 interface 定义对象结构 里继续展开。
如果你想看真实项目里如何用函数类型约束整个数据流,可以延伸阅读本站专题文章 类型优先开发 。
小结
这一节我们围绕「函数签名」建立了三个核心认知:
- 签名是契约。参数类型与返回值类型共同定义了调用方与实现方之间的边界,显式写出公开函数的返回值类型是一种低成本高回报的习惯。
- 可选参数与默认值参数不是一回事。可选参数会引入
undefined,函数体内必须判空;默认值参数不会,且类型可以从默认值推断——两者在参数表中的位置限制也不同。 - 剩余参数与解构是签名的一部分。用元组类型描述
...args、用接口描述解构对象,能让「参数表」本身变成一份可读的文档。
下一节我们会把签名往前推一步:当一个函数需要根据参数类型返回不同类型的结果时,单一签名就不够用了——这就轮到函数重载登场。同时我们还会讲清 this 类型与箭头函数在类型系统里的差异,这两个话题是理解回调、事件与类方法的基础。
阅读导航:上一节:3.3 any·unknown·never·void 与类型断言 · 下一节:4.2 重载、this 类型与箭头函数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。