函数是任何应用的基本构建块,它们也是值,就像TypeScript中的其他值一样。
TypeScript 有很多方法来描述函数如何被调用。让我们学习如何编写类型来描述函数。
函数类型表达式
ts
function greeter(fn: (a: string) => void) {
fn("Hello, World");
}
function printToConsole(s: string) {
console.log(s);
}
greeter(printToConsole);
语法 (a: string) => void
的意思是"一个带有一个参数、名为 a
、类型为 string
、没有返回值的函数"。就像函数声明一样,如果未指定参数类型,则隐式为 any
。
请注意,参数名称是必需的。函数类型
(string) => void
的意思是"一个带有名为string
、类型为any
的参数的函数"
当然,我们可以使用类型别名来命名函数类型
ts
type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
fn("Hello, World");
}
greeter(printToConsole);
调用签名
在 JavaScript 中,函数除了可调用之外还可以具有属性。但是,函数类型表达式语法不允许声明属性。如果我们想用属性描述可调用的东西,我们可以在对象类型中编写调用签名:
ts
type DescribableFunction = {
description: string;
(someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
console.log(fn.description + " returned " + fn(6));
}
function myFunc(someArg: number) {
return someArg > 3;
}
myFunc.description = "default description";
doSomething(myFunc);
请注意,与函数类型表达式相比,语法略有不同 - 在参数列表和返回类型之间使用 :
而不是 =>
。
构造签名
JavaScript 函数也可以使用 new
运算符调用。TypeScript 将它们称为构造函数,因为它们通常会创建一个新对象。你可以通过在调用签名前添加 new
关键字来编写构造签名:
ts
type SomeObject = any;
// ---cut---
type SomeConstructor = {
new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
return new ctor("hello");
}
一些对象,比如 JavaScript 的 Date
对象,可以在有或没有 new
的情况下调用。你可以任意组合相同类型的调用和构造签名:
ts
interface CallOrConstruct {
(n?: number): string; // 可以像函数一样调用,传入一个可选的 number,返回 string
new (s: string): Date; // 也可以像构造函数一样使用,传入 string,返回 Date 实例
}
function fn(ctor: CallOrConstruct) {
// 传入一个类型为 `number` 的参数给 `ctor`,会匹配 `CallOrConstruct` 接口中的第一个定义
console.log(ctor(10));
// ^?
// 同样地,传入一个类型为 `string` 的参数并通过 `new` 使用 `ctor`,会匹配接口中的第二个定义
console.log(new ctor("10"));
// ^?
}
fn(Date);
泛型函数
通常会编写一个函数,其中输入的类型与输出的类型相关,或者两个输入的类型以某种方式相关。
在 TypeScript 中,当我们想要描述两个值之间的对应关系时,会使用泛型。我们通过在函数签名中声明一个类型参数来做到这一点:
ts
function firstElement<Type>(arr: Type[]): Type | undefined {
return arr[0];
}
通过向该函数添加类型参数 Type
并在两个地方使用它,我们在函数的输入(数组)和输出(返回值)之间创建了一个链接。现在当我们调用它时,会出现一个更具体的类型:
ts
declare function firstElement<Type>(arr: Type[]): Type | undefined;
// ---cut---
// s 的类型是 'string'
const s = firstElement(["a", "b", "c"]);
// n 的类型是 'number'
const n = firstElement([1, 2, 3]);
// u 的类型是 undefined
const u = firstElement([]);
推断
请注意,我们不必指定 Type
。类型被TypeScript自动选择推断。
ts
// 自动推断
const n = firstElement([1, 2, 3]);
// 手动指定
const n = firstElement<number>([1, 2, 3]);
//使用多个类型参数
// prettier-ignore
function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
return arr.map(func);
}
// 参数 'n' 的类型是 'string'
// 'parsed' 的类型是 'number[]'
// TypeScript 可以根据函数表达式 (`number`) 的返回值推断 `Input` 类型参数的类型(从给定的 `string` 数组)以及 `Output` 类型参数。
const parsed = map(["1", "2", "3"], (n) => parseInt(n));
约束条件
我们编写了一些泛型函数,可以处理任何类型的值。有时我们想关联两个值,但只能对某个值的子集进行操作。在这种情况下,我们可以使用约束来限制类型参数可以接受的类型种类。
让我们编写一个返回两个值中较长者的函数。为此,我们需要一个 length
属性,它是一个数字。我们通过编写 extends
子句将类型参数限制为该类型:
ts
// @errors: 2345 2322
function longest<Type extends { length: number }>(a: Type, b: Type) {
if (a.length >= b.length) {
return a;
} else {
return b;
}
}
// longerArray 的类型是 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString 的类型是 'alice' | 'bob'
const longerString = longest("alice", "bob");
// 错误!数字类型没有 'length' 属性
const notOK = longest(10, 100);
在这个例子中有一些有趣的事情需要注意。我们允许 TypeScript 推断 longest
的返回类型。返回类型推断也适用于泛型函数。
因为我们将 Type
限制为 { length: number }
,所以我们可以访问 a
和 b
参数的 .length
属性。如果没有类型约束,我们将无法访问这些属性,因为这些值可能是没有长度属性的其他类型。
longerArray
和 longerString
的类型是根据参数推断出来的。请记住,泛型就是将两个或多个具有相同类型的值关联起来!
最后,正如我们所愿,对 longest(10, 100)
的调用被拒绝,因为 number
类型没有 .length
属性。
ts
/**
@errors: 2322 - 类型 '{ length: number; }' 不能赋值给类型 'Type'。
'{ length: number; }' 可以赋值给 'Type' 的约束类型(即 { length: number }),
但 'Type' 可能是这个约束的某个更具体的子类型。
**/
function minimumLength<Type extends { length: number }>(
obj: Type,
minimum: number
): Type {
if (obj.length >= minimum) {
return obj;
} else {
return { length: minimum };
}
}
看起来这个函数似乎还可以 - Type
被约束为 { length: number }
,并且该函数返回 Type
或与该约束匹配的值。问题是该函数 promise 返回与传入相同类型的对象,而不仅仅是与约束匹配的某个对象。
指定类型参数
TypeScript 通常可以在泛型调用中推断出预期的类型参数,但并非总是如此。例如,假设你编写了一个函数来组合两个数组:
ts
function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
return arr1.concat(arr2);
}
// 通常使用不匹配的数组调用此函数会出错:
const arr = combine([1, 2, 3], ["hello"]);
// Type 'string' is not assignable to type 'number'.
// 但是,如果你打算这样做,你可以手动指定 `Type`:
const arr = combine<string | number>([1, 2, 3], ["hello"]);
编写良好泛型函数的指南
编写泛型函数很有趣,而且很容易被类型参数迷住。拥有太多类型参数或在不需要它们的地方使用约束会使推断不太成功,从而使函数的调用者感到沮丧。
下推类型参数
ts
function firstElement1<Type>(arr: Type[]) {
return arr[0];
}
function firstElement2<Type extends any[]>(arr: Type) {
return arr[0];
}
// a: number (good)
const a = firstElement1([1, 2, 3]);
// b: any (bad)
const b = firstElement2([1, 2, 3]);
乍一看,这些似乎相同,但 firstElement1
是编写此函数的更好方法。它推断的返回类型是 Type
,但 firstElement2
的推断返回类型是 any
,因为 TypeScript 必须使用约束类型来解析 arr[0]
表达式,而不是 "等待" 在调用期间解析元素。
规则:如果可能,使用类型参数本身而不是约束它
使用更少的类型参数
这是另一对类似的函数:
ts
function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
return arr.filter(func);
}
function filter2<Type, Func extends (arg: Type) => boolean>(
arr: Type[],
func: Func
): Type[] {
return arr.filter(func);
}
我们创建了一个不关联两个值的类型参数 Func
。这总是一个危险信号,因为这意味着想要指定类型参数的调用者必须无缘无故地手动指定额外的类型参数。Func
没有做任何事情,只是让函数更难阅读和推断!
规则:始终使用尽可能少的类型参数
类型参数应该出现两次
有时我们忘记了函数可能不需要是泛型的:
ts
function greet<Str extends string>(s: Str) {
console.log("Hello, " + s);
}
greet("world");
// 我们可以很容易地编写一个更简单的版本
function greet(s: string) {
console.log("Hello, " + s);
}
请记住,类型参数用于关联多个值的类型。如果一个类型参数只在函数签名中使用一次,它就没有任何关系。这包括推断的返回类型;
例如,如果 Str
是 greet
的推断返回类型的一部分,它将关联参数和返回类型,因此尽管在书面代码中只出现一次,但它会被使用两次。如:
ts
function identity<T>(value: T){
return value;
}
//返回值会被推断为T
规则:如果一个类型参数只出现在一个位置,强烈重新考虑是否真的需要它
可选参数
JavaScript 中的函数通常采用可变数量的参数。例如,number
的 toFixed
方法采用可选的位数计数:
ts
function f(n: number) {
console.log(n.toFixed()); // 0 arguments
console.log(n.toFixed(3)); // 1 argument
}
我们可以通过使用 ?
将参数标记为可选来在 TypeScript 中对此进行建模:
ts
function f(x?: number) {
// ...
}
f(); // OK
f(10); // OK
尽管参数被指定为 number
类型,但 x
参数实际上将具有 number | undefined
类型,因为 JavaScript 中未指定的参数获取值 undefined
。
你还可以提供参数默认值:
ts
function f(x = 10) {
// ...
}
现在在 f
的主体中,x
将具有 number
类型,因为任何 undefined
参数都将被 10
替换。请注意,当参数是可选的时,调用者始终可以传递 undefined
,因为这只是模拟 "missing" 参数:
ts
declare function f(x?: number): void;
// ---cut---
// All OK
f();
f(10);
f(undefined);
回调中的可选参数
一旦你了解了可选参数和函数类型表达式,在编写调用回调的函数时很容易犯以下错误:
ts
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i], i);
}
}
//人们在编写 `index?` 作为可选参数时通常的意图是他们希望这两个调用都是合法的:
myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));
//这实际上意味着 `callback` 可能会被一个参数调用。换句话说,函数定义表明实现可能如下所示:
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
for (let i = 0; i < arr.length; i++) {
// 不提供index
callback(arr[i]);
}
}
// 反过来,TypeScript 将强制执行此含义并触发实际上不可能的错误:
myForEach([1, 2, 3], (a, i) => {
console.log(i.toFixed());
// 'i' is possibly 'undefined'.'i' is possibly 'undefined'.
});
在 JavaScript 中,如果你调用一个实参数多于形参数的函数,多余的参数将被忽略。TypeScript 的行为方式相同。具有较少参数(相同类型)的函数总是可以代替具有更多参数的函数。
规则:为回调编写函数类型时,切勿编写可选参数,除非你打算在不传递该参数的情况下调用该函数
函数重载
可以以各种参数计数和类型调用一些 JavaScript 函数。例如,你可以编写一个函数来生成一个 Date
,它采用时间戳(一个参数)或月/日/年规范(三个参数)。
在 TypeScript 中,我们可以通过编写重载签名来指定一个可以以不同方式调用的函数。为此,请编写一些函数签名(通常是两个或更多),然后是函数的主体:
ts
// @errors: 2575
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d);
} else {
return new Date(mOrTimestamp);
}
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
// - No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.
// 没有任何一个重载版本接受 **2 个参数**,但确实存在接受 **1 个或 3 个参数** 的重载。
const d3 = makeDate(1, 3);
在这个例子中,我们写了两个重载:一个接受一个参数,另一个接受三个参数。前两个签名称为重载签名。
然后,我们编写了一个具有兼容签名的函数实现。函数有一个实现签名,但是这个签名不能直接调用。即使我们在必需的参数之后编写了一个带有两个可选参数的函数,也不能用两个参数调用它!
重载签名和实现签名
这是一个常见的混淆来源。很多时候人们会写这样的代码,却不明白为什么会出现错误:
ts
// @errors: 2554
function fn(x: string): void;
function fn() {
// ...
}
// -Expected 1 arguments, but got 0.
fn();
同样,用于编写函数体的签名无法从外部 "看到"。
从外部看不到实现的签名。在编写重载函数时,你应该始终在函数实现之上有两个或多个签名。
实现签名还必须与重载签名兼容。例如,这些函数有错误,因为实现签名没有以正确的方式匹配重载:
ts
// @errors: 2394
function fn(x: boolean): void;
// 参数不匹配
function fn(x: string): void;
function fn(x: boolean) {}
// @errors: 2394
function fn(x: string): string;
// 返回值不匹配
function fn(x: number): boolean;
function fn(x: string | number) {
return "oops";
}
编写好的重载
与泛型一样,在使用函数重载时应该遵循一些准则。遵循这些原则将使你的函数更易于调用、更易于理解和更易于实现。
让我们考虑一个返回字符串或数组长度的函数:
ts
function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
return x.length;
}
// @errors: 2769
declare function len(s: string): number;
declare function len(arr: any[]): number;
// ---cut---
len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]);
/**
没有任何一个重载版本匹配这个调用。
重载版本 1:(s: string): number,报错:
参数类型 'number[] | "hello"' 不能赋值给 'string' 类型。
'number[]' 不能赋值给 'string'。
重载版本 2:(arr: any[]): number,报错:
参数类型 'number[] | "hello"' 不能赋值给 'any[]' 类型。
'string' 不能赋值给 'any[]'。
**/
因为两个重载具有相同的参数计数和相同的返回类型,我们可以改为编写函数的非重载版本:
ts
function len(x: any[] | string) {
return x.length;
}
这好多了!调用者可以使用任何一种值来调用它,作为额外的好处,我们不必找出正确的实现签名。
尽可能使用联合类型的参数而不是重载
在函数中声明 this
TypeScript 将通过代码流分析推断函数中的 this
应该是什么。
TypeScript 理解函数 user.becomeAdmin
有一个对应的 this
,它是外部对象 user
。this
,呵呵,很多情况下就够用了,但是很多情况下,你需要更多的控制 this
代表什么对象。JavaScript 规范规定你不能有一个名为 this
的参数,因此 TypeScript 使用该语法空间让你在函数体中声明 this
的类型。
ts
const user = {
id: 123,
admin: false,
becomeAdmin: function () {
this.admin = true;
},
};
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
const admins = db.filterUsers(function (this: User) {
return this.admin;
});
这种模式在回调风格的 API 中很常见,其中另一个对象通常控制何时调用你的函数。请注意,你需要使用 function
而不是箭头函数来获得此行为:
ts
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
//> 包含该箭头函数的上下文捕获了全局的 this 值。
//> 因为类型 'typeof globalThis' 没有索引签名,所以元素隐式具有 'any' 类型。
const admins = db.filterUsers(() => this.admin);
其他需要了解的类型
在使用函数类型时,你需要识别一些经常出现的其他类型。像所有类型一样,你可以在任何地方使用它们,但这些在函数的上下文中尤其相关。
void
void
表示不返回值的函数的返回值。只要函数没有任何 return
语句,或者没有从这些返回语句返回任何显式值,它就是推断类型:
在 JavaScript 中,不返回任何值的函数将隐式返回值 undefined
。但是,void
和 undefined
在 TypeScript 中不是一回事。本章末尾有更多详细信息。
void
与undefined
不同。
object
特殊类型 object
指的是任何非基础值(string
、number
、bigint
、boolean
、symbol
、null
或 undefined
)。这与空对象类型 { }
不同,也与全局类型 Object
不同。你很可能永远不会使用 Object
。
object
不是Object
。始终使用object
!
请注意,在 JavaScript 中,函数值是对象:它们有属性,在它们的原型链中有 Object.prototype
,是 instanceof Object
,你可以在它们上调用 Object.keys
,等等。因此,函数类型在 TypeScript 中被视为 object
。
unknown
unknown
类型代表任何值。这类似于 any
类型,但更安全,因为使用 unknown
值做任何事情都是不合法的:
ts
function f1(a: any) {
a.b(); // OK
}
function f2(a: unknown) {
a.b();
// 'a' is of type 'unknown'.'a' is of type 'unknown'.
}
这在描述函数类型时很有用,因为你可以描述接受任何值而不在函数体中包含 any
值的函数。
相反,你可以描述一个返回未知类型值的函数:
ts
function safeParse(s: string): unknown {
return JSON.parse(s);
}
// Need to be careful with 'obj'!
const obj = safeParse(someRandomString);
Try
never
有些函数从不返回值:
ts
function fail(msg: string): never {
throw new Error(msg);
}
never
类型表示从未观察到的值。在返回类型中,这意味着函数抛出异常或终止程序的执行。
当 TypeScript 确定联合中没有任何内容时,never
也会出现。
ts
function fn(x: string | number) {
if (typeof x === "string") {
// do something
} else if (typeof x === "number") {
// do something else
} else {
x; // has type 'never'!
}
}
Function
全局类型 Function
描述了 bind
、call
、apply
等属性,以及 JavaScript 中所有函数值上的其他属性。它还具有 Function
类型的值始终可以被调用的特殊属性;这些调用返回 any
:
ts
function doSomething(f: Function) {
return f(1, 2, 3);
}
这是一个无类型的函数调用,通常最好避免,因为不安全的 any
返回类型。
如果你需要接受任意函数但不打算调用它,则类型 () => void
通常更安全。
剩余形参和实参
剩余形参
除了使用可选参数或重载来制作可以接受各种固定参数计数的函数之外,我们还可以使用剩余参数定义接受无限数量参数的函数。
剩余参数出现在所有其他参数之后,并使用 ...
语法:
ts
function multiply(n: number, ...m: number[]) {
return m.map((x) => n * x);
}
// 'a' gets value [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);
在 TypeScript 中,这些参数上的类型注释隐式为 any[]
而不是 any
,并且给出的任何类型注释必须采用 Array<T>
或 T[]
形式,或者元组类型(稍后我们将了解)。
剩余实参
相反,我们可以使用扩展语法从可迭代对象(例如数组)中提供可变数量的参数。例如,数组的 push
方法接受任意数量的参数:
ts
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);
请注意,通常,TypeScript 并不假定数组是不可变的。这可能会导致一些令人惊讶的行为:
ts
// 推断的类型是 number[] ------ 表示"一个包含 0 个或多个数字的数组",**而不是特定的两个数字**
const args = [8, 5];
// // 扩展参数(spread argument)必须具有元组类型,或者被传递给一个**剩余参数(rest parameter)**
const angle = Math.atan2(...args);
这种情况的最佳解决方案取决于你的代码,但通常 const
上下文是最直接的解决方案:
ts
// Inferred as 2-length tuple
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);
参数解构
你可以使用参数解构来方便地将作为参数提供的对象解包到函数体中的一个或多个局部变量中。在 JavaScript 中,它看起来像这样:
ts
function sum({ a, b, c }) {
console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });
对象的类型注释遵循解构语法:
ts
function sum({ a, b, c }: { a: number; b: number; c: number }) {
console.log(a + b + c);
}
Try
这看起来有点冗长,但你也可以在此处使用命名类型:
ts
// Same as prior example
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
console.log(a + b + c);
}
函数的可赋值性
返回类型 void
函数的 void
返回类型可能会产生一些不寻常但预期的行为。
返回类型为 void
的上下文类型不会强制函数不返回某些内容。另一种说法是具有 void
返回类型 (type voidFunc = () => void
) 的上下文函数类型,当实现时,可以返回任何其他值,但会被忽略。
因此,() => void
类型的以下实现是有效的:
ts
type voidFunc = () => void;
const f1: voidFunc = () => {
return true;
};
const f2: voidFunc = () => true;
const f3: voidFunc = function () {
return true;
};
Try
并且当其中一个函数的返回值被赋值给另一个变量时,它会保留 void
的类型:
ts
const v1 = f1();
const v2 = f2();
const v3 = f3();
这种行为的存在使得即使 Array.prototype.push
返回一个数字并且 Array.prototype.forEach
方法需要一个返回类型为 void
的函数,以下代码也是有效的。
ts
const src = [1, 2, 3];
const dst = [0];
src.forEach((el) => dst.push(el));
还有另一种特殊情况需要注意,当字面量函数定义具有 void
返回类型时,该函数不得返回任何内容。
ts
function f2(): void {
// @ts-expect-error
return true;
}
const f3 = function (): void {
// @ts-expect-error
return true;
};
有关 void
的更多信息,可以参阅这些文档条目: