《TypeScript编程入门》4.1 签名、可选参数与默认值

函数是 TypeScript 程序的基本构件,而函数签名则决定了调用方能看到什么、编译器能替你检查什么。本节从函数类型注解讲起,逐步展开参数与返回值的标注方式、可选参数与默认值的语义差异、剩余参数与参数解构的写法,并给出严格模式下的常见报错与修复思路。读完后你将能写出签名清晰、对调用方友好、且能被编译器完整校验的函数,为后面学习重载与泛型打下基础。

本节目标:理解「函数签名」这个概念的准确含义,掌握参数注解、返回值注解、可选参数、默认值参数、剩余参数与参数解构的写法;能够解释可选参数与默认值参数在类型层面的区别,并读懂严格模式下常见的函数相关报错。学完本节,你写的每一个函数都会先有一份「契约」,再有一份「实现」。

4.1 签名、可选参数与默认值

在上一章 3.3 any·unknown·never·void 与类型断言 里,我们已经见过函数返回 void 与 never 的样子。但那时我们关注的是「返回值类型」,没有认真看函数本身的结构。这一节把镜头转向函数的正面:签名(signature)。

什么是函数签名

函数签名是对一个函数「外部可见部分」的完整描述,它由三块组成:

  1. 函数名(在作为值传递时可以是匿名的);
  2. 参数列表,每个参数的名字、类型、是否可选;
  3. 返回值类型。

一个函数签名回答的问题是:调用方需要提供什么,能拿回什么。函数体内部怎么实现,不属于签名的一部分。

// 这个函数的签名是: (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 | undefinednumber
函数体内部必须判 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 定义对象结构 里继续展开。

如果你想看真实项目里如何用函数类型约束整个数据流,可以延伸阅读本站专题文章 类型优先开发 。

小结

这一节我们围绕「函数签名」建立了三个核心认知:

  1. 签名是契约。参数类型与返回值类型共同定义了调用方与实现方之间的边界,显式写出公开函数的返回值类型是一种低成本高回报的习惯。
  2. 可选参数与默认值参数不是一回事。可选参数会引入 undefined,函数体内必须判空;默认值参数不会,且类型可以从默认值推断——两者在参数表中的位置限制也不同。
  3. 剩余参数与解构是签名的一部分。用元组类型描述 ...args、用接口描述解构对象,能让「参数表」本身变成一份可读的文档。

下一节我们会把签名往前推一步:当一个函数需要根据参数类型返回不同类型的结果时,单一签名就不够用了——这就轮到函数重载登场。同时我们还会讲清 this 类型与箭头函数在类型系统里的差异,这两个话题是理解回调、事件与类方法的基础。

阅读导航:上一节:3.3 any·unknown·never·void 与类型断言 · 下一节:4.2 重载、this 类型与箭头函数 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes