阶段二 · 类型系统基础

函数, 参数类型与返回值

前置回顾

  • 第 02 篇讲了 const/let 声明变量, 以及 number, string, boolean 等基础类型
  • 类型注解写在变量名后面, 大多数情况编译器能自动推断
  • 本篇进入函数: TS 中最常手写类型标注的地方

一句话总结

函数参数必须标注类型(不会推断), 返回值通常可以省略(会推断). TS 支持箭头函数, 可选参数, 默认值, 剩余参数, 重载, 以及将函数类型作为值传递.

函数声明的几种方式

TS 有三种写法定义函数, 最终编译结果几乎等价:

// 1. function 声明(具名函数)
function add(a: number, b: number): number {
return a + b;
}

// 2. 箭头函数(=> 语法)
const add2 = (a: number, b: number): number => {
return a + b;
};

// 3. 箭头函数简写: 单表达式省略 {} 和 return
const add3 = (a: number, b: number): number => a + b;

对比其他语言:

// Go: 只有一种函数声明
func add(a int, b int) int {
return a + b
}
// Go 匿名函数
var add2 = func(a, b int) int { return a + b }
// Rust: fn 声明 + 闭包
fn add(a: i32, b: i32) -> i32 { a + b }
let add2 = |a: i32, b: i32| -> i32 { a + b };

何时用哪种

场景 推荐写法
模块顶层的独立函数 function 声明
回调, 传参, 短逻辑 箭头函数
需要 this 绑定外层上下文 箭头函数
需要函数提升(调用写在声明前) function 声明

function 声明会提升

function 声明有提升(hoisting), 可以先调用再定义. 箭头函数赋值给 const 则不行:

greet("Alice"); // 正常, function 声明被提升

function greet(name: string) { console.log(name); }
greet("Alice"); // 编译错误: Block-scoped variable used before declaration

const greet = (name: string) => { console.log(name); };

参数类型标注

函数参数不会被推断, 必须手动标注. 这是 TS 中最常写类型的位置:

function repeat(str: string, times: number): string {
return str.repeat(times);
}

可选参数

参数名后加 ? 表示可以不传, 类型自动变为 T | undefined:

function greet(name: string, greeting?: string): string {
return `${greeting || "Hello"}, ${name}`;
}

greet("Alice"); // "Hello, Alice"
greet("Alice", "Hi"); // "Hi, Alice"

Go 没有可选参数, 通常用 Options 结构体或函数式选项模式替代. TS 的 ? 更直接.

可选参数必须在必选参数后面

function bad(a?: string, b: number) {} // 编译错误
function ok(b: number, a?: string) {} // 正确

默认参数

给参数赋默认值, 不传时使用默认值. 有默认值的参数不需要 ?:

function createServer(port: number = 3000, host: string = "localhost") {
console.log(`${host}:${port}`);
}

createServer(); // localhost:3000
createServer(8080); // localhost:8080
createServer(8080, "0.0.0.0");

编译器从默认值推断参数类型, 所以 port: number = 3000 中的 : number 可以省略为 port = 3000.

剩余参数

用 ... 收集不定数量的参数为数组:

function sum(...numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0);
}

sum(1, 2, 3); // 6
sum(1, 2, 3, 4); // 10

对比 Go 的变参:

func sum(numbers ...int) int {
total := 0
for _, n := range numbers {
total += n
}
return total
}

语法几乎对称: Go 的 ...int 放在类型前, TS 的 ...numbers: number[] 放在参数名前.

返回值类型

显式标注 vs 自动推断

// 显式标注返回类型
function add(a: number, b: number): number {
return a + b;
}

// 省略: 编译器从 return 语句推断为 number
function add2(a: number, b: number) {
return a + b;
}

什么时候该显式写

三种情况建议显式标注返回类型:

  1. 公共 API / 导出函数: 返回类型是契约, 防止实现意外改变时影响调用方
  2. 递归函数: 编译器有时推断不出递归的返回类型
  3. 复杂逻辑: 多个 return 路径, 显式标注能帮助发现遗漏
// 导出函数: 显式标注作为契约
export function parseConfig(raw: string): AppConfig {
return JSON.parse(raw);
}

// 内部工具函数: 省略即可
const double = (n: number) => n * 2;

void: 无返回值

函数不返回有意义的值时, 返回类型为 void:

function log(msg: string): void {
console.log(msg);
}

void 表示调用方不应使用返回值. 实际运行时返回 undefined, 但类型层面禁止对它做任何操作.

多返回值

Go 的多返回值在 TS 中不存在. 替代方案:

// 方案一: 返回元组
function divide(a: number, b: number): [number, boolean] {
if (b === 0) return [0, false];
return [a / b, true];
}
const [result, ok] = divide(10, 3);

// 方案二: 返回对象(更常用, 字段有名字)
function divide2(a: number, b: number): { result: number; ok: boolean } {
if (b === 0) return { result: 0, ok: false };
return { result: a / b, ok: true };
}
const { result: r, ok } = divide2(10, 3);

对比 Go:

func divide(a, b float64) (float64, bool) {
if b == 0 { return 0, false }
return a / b, true
}
result, ok := divide(10, 3)

推荐: 返回对象而非元组

超过 2 个返回值时用对象. 元组的 [0], [1] 可读性差, 对象字段名自解释.
Go 靠命名返回值(result float64, ok bool)解决可读性, TS 靠对象.

函数重载

同一个函数名, 根据参数不同做不同的事. TS 用重载签名实现:

// 重载签名(只有类型, 没有函数体)
function format(value: string): string;
function format(value: number): string;

// 实现签名(必须兼容所有重载签名)
function format(value: string | number): string {
if (typeof value === "string") {
return value.trim();
}
return value.toFixed(2);
}

format("hello "); // 返回 string, 调用第一个签名
format(3.14159); // 返回 string, 调用第二个签名
format(true); // 编译错误: 没有匹配的重载

对比其他语言:

语言 重载方式
Java 同名方法, 参数列表不同
Go 不支持重载, 用不同函数名
Rust 不支持重载, 用 trait / enum 分发
TypeScript 重载签名 + 单一实现

重载不是多个函数体

TS 的重载和 Java 不同: 只有一个实现, 内部靠类型判断分支. 重载签名只用于调用方的类型检查, 不生成多份代码.

多数情况下, 联合类型 + 类型收窄比重载更简洁. 只在"参数类型和返回类型有对应关系"时才用重载.

函数类型表达式

函数本身也是值, 它的类型可以这样描述:

// 定义函数类型
type MathFn = (a: number, b: number) => number;

// 使用函数类型
const add: MathFn = (a, b) => a + b; // 参数类型从 MathFn 推断, 不用再写
const multiply: MathFn = (a, b) => a * b;

对比 Go:

type MathFn func(int, int) int

var add MathFn = func(a, b int) int { return a + b }

函数类型最常用于回调参数:

function applyOperation(a: number, b: number, fn: (x: number, y: number) => number): number {
return fn(a, b);
}

applyOperation(3, 4, (x, y) => x + y); // 7

用 type 抽出来可读性更好:

type BinaryOp = (x: number, y: number) => number;

function applyOperation(a: number, b: number, fn: BinaryOp): number {
return fn(a, b);
}

this 的问题

Go 和 Rust 中, 方法的接收者是显式的(self / *MyStruct). JS/TS 的 this 取决于调用方式, 不取决于定义位置:

const user = {
name: "Alice",
greet() {
console.log(this.name); // this 是 user
},
};

user.greet(); // "Alice"

const fn = user.greet;
fn(); // undefined -- this 丢失了

Go/Rust 的 self = 写在函数签名里的参数, 调用时编译器自动填.
JS 的 this = 运行时根据"谁调了这个函数"动态决定, 像一张临时工牌, 随调用方式变.

箭头函数固定 this

箭头函数不创建自己的 this, 而是捕获定义时外层的 this:

const user = {
name: "Alice",
greet() {
// 箭头函数继承 greet() 的 this
const inner = () => console.log(this.name);
inner(); // "Alice" -- this 没丢
},
};

这是箭头函数在回调场景中被优先选择的核心原因.

为 this 标注类型

TS 允许把 this 作为第一个伪参数标注类型(不影响调用):

interface Button {
label: string;
onClick(this: Button): void;
}

const btn: Button = {
label: "Submit",
onClick() {
console.log(this.label); // this 类型确定为 Button
},
};

实际使用中

大多数时候不需要手动标注 this. 用箭头函数或 class 方法可以避免 this 问题.
只在定义回调接口或事件处理器签名时偶尔用到.

泛型函数入门

有时函数的输入和输出类型相关, 但具体是什么类型不确定. 用泛型参数 <T> 表示:

function identity<T>(arg: T): T {
return arg;
}

identity<string>("hello"); // 显式指定 T = string
identity(42); // T 被推断为 number(更常用)

三语言对比:

// TypeScript
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// Go
func First[T any](arr []T) (T, bool) {
if len(arr) == 0 { var zero T; return zero, false }
return arr[0], true
}
// Rust
fn first<T>(arr: &[T]) -> Option<&T> {
arr.first()
}

泛型的详细用法(约束, 多类型参数, 工具类型)在阶段三专门讲. 这里只需记住: <T> 是类型的"参数", 让函数适配多种类型而不丢失类型信息.

泛型和 any 的区别

any 放弃了类型信息: 传入 string, 返回也是 any, 调用方拿到 any 后续操作全无检查.
泛型保留类型关联: 传入 string, 返回就是 string. 类型安全没有丢失.

回调与高阶函数

函数可以作为参数传递, 也可以作为返回值. 这在 TS 中极为常见:

// map: 对数组每个元素施加变换
const numbers = [1, 2, 3, 4];
const doubled = numbers.map((n) => n * 2); // [2, 4, 6, 8]
const strings = numbers.map((n) => String(n)); // ["1", "2", "3", "4"]

// filter: 筛选满足条件的元素
const evens = numbers.filter((n) => n % 2 === 0); // [2, 4]

// reduce: 累积计算
const sum = numbers.reduce((acc, n) => acc + n, 0); // 10

对比 Go(需要自己写循环或用泛型工具库):

// Go 1.21+ slices 包
doubled := slices.Collect(func(yield func(int) bool) {
for _, n := range numbers { yield(n * 2) }
})

对比 Rust:

let doubled: Vec<i32> = numbers.iter().map(|n| n * 2).collect();

自定义高阶函数

返回函数的函数:

function createMultiplier(factor: number): (n: number) => number {
return (n) => n * factor;
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

double(5); // 10
triple(5); // 15

这就是闭包: 内部函数捕获外部变量 factor. 和 Go/Rust 的闭包概念完全一致, 只是语法不同.

快速回顾

  • 三种声明: function 具名声明(有提升), 箭头函数(绑定外层 this), 函数表达式
  • 参数类型: 必须手动标注, 支持可选(?), 默认值(= x), 剩余参数(...args)
  • 返回值: 通常靠推断, 导出函数和递归建议显式标注
  • 多返回值: TS 没有, 用对象或元组替代
  • 函数重载: 多个签名 + 单一实现, 只在参数/返回类型有对应关系时使用
  • 函数类型: type Fn = (a: T) => R, 用于回调参数和变量声明
  • this: 动态绑定, 箭头函数固定为外层 this
  • 泛型: <T> 是类型参数, 保留类型关联而不丢失为 any

动手练习

  1. 基础函数: 写一个 clamp(value, min, max) 函数, 将 value 限制在 [min, max] 范围内, 标注完整类型.
  2. 可选参数: 写一个 formatName(first, last?, title?) 函数, 根据传入参数数量组合出不同格式的名字.
  3. 回调参数: 写一个 retry(fn, times) 函数, 接收一个可能失败的回调, 失败时重试 times 次.
  4. 函数类型: 定义一个 type Comparator<T> = (a: T, b: T) => number, 然后写一个通用排序函数使用它.
  5. 泛型入门: 写一个 function last<T>(arr: T[]): T | undefined 返回数组最后一个元素.
  6. 重载练习: 写一个 stringify 函数, 接收 number 返回固定小数位字符串, 接收 Date 返回 ISO 格式字符串, 用重载签名实现.