免责声明: 本文为翻译自 Typescript 官方手册内容,非原创。版权归原作者所有。
原文来源: More on Functions
翻译者注: 本人在翻译过程中已尽最大努力确保内容的准确性和完整性,但翻译工作难免有疏漏。如读者在阅读中发现任何问题,欢迎提出建议或直接查阅原文以获取最准确的信息。
函数是任何应用程序的基本构建块,无论它们是本地函数,从另一个模块导入的,还是类上的方法。它们也是值,就像其他值一样,TypeScript 有许多方式来描述如何调用函数。让我们学习如何编写描述函数的类型。
函数类型表达式
描述函数的最简单方法是使用函数类型表达式。这些类型在语法上类似于箭头函数:
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 的参数的函数”!
当然,我们可以使用类型别名来命名一个函数类型:
type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
// ...
}
调用签名
在JavaScript中,函数除了可以被调用之外,还可以拥有属性。然而,函数类型表达式的语法不允许声明属性。如果我们想要描述一个具有属性的可调用项,我们可以在对象类型中写一个调用签名:
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 关键字来编写一个构造签名:
type SomeConstructor = {
new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
return new ctor("hello");
}
一些对象,如 JavaScript 的 Date 对象,可以使用或不使用 new 来调用。你可以在同一类型中任意组合调用和构造签名:
interface CallOrConstruct {
(n?: number): string;
new (s: string): Date;
}
泛型函数
通常编写一个函数,其中输入的类型与输出的类型相关,或者两个输入的类型以某种方式相关。 让我们考虑一下返回数组第一个元素的函数:
function firstElement(arr: any[]) {
return arr[0];
}
这个函数能完成其工作,但不幸的是它的返回类型为 any。如果函数返回数组元素的类型,那就更好了。
在 TypeScript 中,当我们想要描述两个值之间的对应关系时,会使用泛型。我们通过在函数签名中声明一个类型参数来实现这一点。
function firstElement<Type>(arr: Type[]): Type | undefined {
return arr[0];
}
通过在此函数中添加一个类型参数 Type 并在两个地方使用它,我们创建了函数输入(数组)和输出(返回值)之间的链接。现在当我们调用它时,会得到更具体的类型:
// s 是 'string'
const s = firstElement(["a", "b", "c"]);
// n 是 'number'
const n = firstElement([1, 2, 3]);
// u 是 undefined
const u = firstElement([]);
类型推断
注意,在这个示例中,我们并没有指定 Type。类型是由 TypeScript 自动推断 - 自动选择的。
我们也可以使用多个类型参数。例如,map 的独立版本看起来会像这样:
function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
return arr.map(func);
}
// 参数 'n' 是 'string'
// 'parsed' 是 'number[]'
const parsed = map(["1", "2", "3"], (n) => parseInt(n));
请注意,在此示例中,TypeScript 可以推断出 Input 类型参数的类型(来自给定的 string 数组),以及基于函数表达式的返回值(数字)推断出 Output 类型参数。
类型约束
我们编写了一些泛型函数,可以对任何类型的值进行操作。有时候我们想要关联两个值,但只能对值的某个子集进行操作。在这种情况下,我们可以使用约束来限制类型参数可以接受的类型。
让我们编写一个函数,用于返回两个值中较长的一个。为此,我们需要一个类型为数字的 length 属性。我们通过编写一个 extends 子句来将类型参数约束为那种类型:
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);
// Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
在这个例子中有几点值得注意。我们让 TypeScript 推断出 longest 的返回类型。返回类型推断也适用于泛型函数。
因为我们将 Type 限制为 { length: number },所以我们被允许访问 a 和 b 参数的 .length 属性。如果没有类型约束,我们将无法访问这些属性,因为值可能是没有长度属性的其他类型。
longerArray 和 longerString 的类型是根据参数推断出来的。记住,泛型就是关于将两个或更多相同类型的值相关联!
最后,正如我们所愿,对 longest(10, 100) 的调用被拒绝了,因为 number 类型没有 .length 属性。
处理受约束的值
这是一个在处理泛型约束时常见的错误:
function minimumLength<Type extends { length: number }>(obj: Type, minimum: number): Type {
if (obj.length >= minimum) {
return obj;
} else {
return { length: minimum };
// 不能将类型 “{ length: number; }” 分配给类型 “Type”。
// "{ length: number; }" 可赋给 "Type" 类型的约束,
// 但可以使用约束 "{ length: number; }" 的其他子类型实例化 "Type"。
}
}
这个函数看起来可能没问题 - Type 被限制为 { length: number },并且函数要么返回 Type,要么返回符合该约束的值。但问题在于,该函数承诺返回与传入的对象相同类型的对象,而不仅仅是满足约束的某个对象。如果这段代码是合法的,以下编写的代码肯定无法正常工作:
// 'arr' 获得值 { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
// 在这里会崩溃,因为数组有 'slice'方法,但返回的对象却没有!
console.log(arr.slice(0));
指定类型参数
TypeScript 通常可以推断出泛型调用中的预期的类型参数,但并非总是如此。例如,假设你编写了一个函数来合并两个数组:
function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
return arr1.concat(arr2);
}
通常,如果使用不匹配的数组调用此函数,将会出错。
const arr = combine([1, 2, 3], ["hello"]);
// 不能将类型 “string” 分配给类型 “number”。
如果你打算这样做,那么,你可以手动指定类型:
const arr = combine<string | number>([1, 2, 3], ["hello"]);
编写良好泛型函数的指导原则
编写通用函数很有趣,而且可能会让人沉迷于类型参数。过多的类型参数或在不需要的地方使用约束可能会使推断效果降低,令调用你函数的人感到困扰。
向下传递类型参数
这里有两种编写函数的方法看起来很相似:
function firstElement1<Type>(arr: Type[]) {
return arr[0];
}
function firstElement2<Type extends any[]>(arr: Type) {
return arr[0];
}
// a: number (好)
const a = firstElement1([1, 2, 3]);
// b: any (不好)
const b = firstElement2([1, 2, 3]);
这两者乍一看可能相同,但 firstElement1 是编写此类函数的更好方式。它推断的返回类型是 Type,而 firstElement2 的推断返回类型是 any,因为 TypeScript 必须使用约束类型来解析 arr[0] 表达式,而不是在调用期间“等待”解析元素。
规则:尽可能使用类型参数本身,而不是对其进行约束。
使用更少的类型参数
这是另一组相似的函数:
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 除了使函数更难读懂和推理外,没有任何作用!
规则:尽可能少地使用类型参数
类型参数应出现两次
有时候我们忘记了,一个函数可能并不需要是泛型的。
function greet<Str extends string>(s: Str) {
console.log("Hello, " + s);
}
greet("world");
我们本可以写一个更简单的版本:
function greet(s: string) {
console.log("Hello, " + s);
}
记住,类型参数用于关联多个值的类型。如果一个类型参数在函数签名中只使用一次,那么它就没有关联任何东西。这包括推断的返回类型;例如,如果 Str 是 greet 的推断返回类型的一部分,那么它将会关联参数和返回类型,因此尽管在编写代码时只出现了一次,但实际上被使用了两次。
规则:如果类型参数只出现在一个位置,强烈建议你重新考虑是否真的需要它。
可选参数
在 JavaScript 中,函数通常接受可变数量的参数。例如,number 类型的 toFixed 方法可以选择性地接受一个位数计数:
function f(n: number) {
console.log(n.toFixed()); // 0 个参数
console.log(n.toFixed(3)); // 1 个参数
}
我们可以通过在参数后面加 ? 来将其标记为可选,以此在 TypeScript 中建立模型。
function f(x?: number) {
// ...
}
f(); // OK
f(10); // OK
尽管参数被指定为 number 类型,但实际上 x 参数将具有 number | undefined 的类型,因为在 JavaScript 中未指定的参数会得到 undefined 值。
你也可以提供一个默认的参数:
function f(x = 10) {
// ...
}
现在,在函数 f 的主体中,x 将具有 number 类型,因为任何 undefined 参数都将被替换为 10。请注意,当参数是可选的时候,调用者总是可以传递 undefined,因为这只是模拟了一个“缺失”的参数。
declare function f(x?: number): void;
// 都可以
f();
f(10);
f(undefined);
回调中的可选参数
一旦你学习了可选参数和函数类型表达式,编写调用回调的函数时很容易犯以下错误:
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));
这实际上意味着回调函数可能只带有一个参数进行调用。换句话说,函数的定义表明了其实现可能会像下面这样:
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
for (let i = 0; i < arr.length; i++) {
// 我今天不想提供索引
callback(arr[i]);
}
}
反过来,TypeScript将按这个意思强制执行,并发出实际上不可能的错误:
myForEach([1, 2, 3], (a, i) => {
console.log(i.toFixed());
// 报错:“i”可能为“未定义”
});
在 JavaScript 中,如果你调用一个函数时提供的参数多于函数本身的参数,那么多余的参数将会被忽略。TypeScript 也是这样处理的。拥有较少参数(类型相同)的函数总是可以替代拥有更多参数的函数。
规则:编写回调函数类型时,除非你打算在不传递该参数的情况下调用函数,否则永远不要写可选参数。
函数重载
一些 JavaScript 函数可以以各种参数数量和类型进行调用。例如,你可能会编写一个函数来生成日期,该函数接受时间戳(1个参数)或月/日/年规格(3个参数)。
在 TypeScript 中,我们可以通过编写重载签名来指定可以以不同方式调用的函数。为此,写一些函数签名(通常是两个或更多),然后跟随着函数的主体:
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);
const d3 = makeDate(1, 3);
// 报错:没有需要 2 参数的重载,但存在需要 1 或 3 参数的重载。
在这个例子中,我们写了两个重载:一个接受 1 个参数,另一个接受 3 个参数。这前两个签名被称为重载签名。
然后,我们写了一个具有兼容签名的函数实现。函数有一个实现签名,但这个签名不能直接调用。尽管我们在必需参数之后写了带有两个可选参数的函数,但它不能用两个参数来调用!
重载签名与实现签名
这是一个常见的混淆源。人们经常会写出这样的代码,却不明白为什么会有错误:
function fn(x: string): void;
function fn() {
// ...
}
// 预期能够无参数调用
fn();
// 报错:应有 1 个参数,但获得 0 个。
再次强调,用于编写函数体的签名在外部是“看不见”的。
实现的签名从外部是不可见的。编写重载函数时,你应该总是在函数实现之上有两个或更多的签名。
实现签名也必须与重载签名兼容。例如,这些函数存在错误,因为实现签名与重载的方式不匹配:
function fn(x: boolean): void;
// 参数类型不正确
function fn(x: string): void;
// 报错:此重载签名与其实现签名不兼容。
function fn(x: boolean) {}
function fn(x: string): string;
// 返回类型不正确
function fn(x: number): boolean;
// 报错:此重载签名与其实现签名不兼容。
function fn(x: string | number) {
return "oops";
}
编写良好的重载
像泛型一样,使用函数重载时你应遵循一些指导原则。遵循这些原则将使你的函数更易于调用,更易于理解,并且更易于实现。
让我们考虑一个返回字符串或数组长度的函数:
function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
return x.length;
}
这个函数是没有问题的,我们可以用字符串或数组来调用它。然而,我们不能用可能是字符串或数组的值来调用它,因为TypeScript只能解析到单一重载的函数调用。
len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]);
// 没有与此调用匹配的重载。
// 第 1 个重载(共 2 个),“(s: string): number”,出现以下错误。
// 类型“number[] | "hello"”的参数不能赋给类型“string”的参数。
// 不能将类型“number[]”分配给类型“string”。
// 第 2 个重载(共 2 个),“(arr: any[]): number”,出现以下错误。
// 类型“number[] | "hello"”的参数不能赋给类型“any[]”的参数。
// 不能将类型“string”分配给类型“any[]”。
因为这两种重载都具有相同的参数数量和相同的返回类型,我们可以改写一个非重载版本的函数:
function len(x: any[] | string) {
return x.length;
}
这要好得多!无论使用哪种值,调用者都可以调用此功能。作为额外的奖励,我们无须找出正确的实现签名。
尽可能总是优先选择带有联合类型的参数,而不是重载。
在函数中声明 this
TypeScript 将通过代码流分析推断函数中的 this 应该是什么,例如在以下情况:
const user = {
id: 123,
admin: false,
becomeAdmin: function () {
this.admin = true;
},
};
TypeScript 明白函数 user.becomeAdmin 有一个对应的 this,这个 this 就是外部对象 user。嘿,这在很多情况下可能已经足够了,但也有很多情况下你需要更多地控制 this 代表什么对象。JavaScript 规范规定你不能有一个叫做 this 的参数,因此 TypeScript 使用那个语法空间让你在函数体中声明 this 的类型。
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
const admins = db.filterUsers(function (this: User) {
return this.admin;
});
这种模式在回调风格的API中很常见,通常由另一个对象控制何时调用你的函数。注意,你需要使用 function 而不是箭头函数来获得此行为:
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
const admins = db.filterUsers(() => this.admin);
// The containing arrow function captures the global value of 'this'.
// Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.
需要知道的其它类型
在处理函数类型时,你需要识别一些经常出现的附加类型。像所有类型一样,你可以在任何地方使用它们,但是在函数的上下文中,这些尤其相关。
void
void 代表不返回值的函数的返回值。每当一个函数没有任何 return 语句,或者从这些返回语句中没有明确地返回任何值时,它就是推断出的类型:
// 推断的返回类型是 void
function noop() {
return;
}
在 JavaScript 中,一个不返回任何值的函数会隐式地返回 undefined 值。然而,在 TypeScript 中,void 和 undefined 并不是同一回事。本章节末尾有更详细的解释。
void 并不等同于 undefined。
object
特殊类型 object 指的是任何非原始值(string,number,bigint,boolean,symbol,null 或 undefined)。这与空对象类型 { } 不同,也与全局类型 Object 不同。你很可能永远不会使用 Object。
object 并非 Object。始终使用 object!
请注意,在 JavaScript 中,函数值是 object:它们有属性,Object.prototype 在它们的原型链中,是 instanceof Object,你可以在它们上面调用 Object.keys 等。因此,在 TypeScript 中,函数类型被视为 object。
unknown
unknown 类型代表任何值。这与 any 类型相似,但更安全,因为对 unknown 值进行任何操作都是不合法的:
function f1(a: any) {
a.b(); // OK
}
function f2(a: unknown) {
a.b();
// 报错:“a”的类型为“未知”。
}
这在描述函数类型时很有用,因为你可以描述接受任何值的函数,而无需在你的函数体中有 any 值。
相反地,你可以描述一个返回未知类型值的函数:
function safeParse(s: string): unknown {
return JSON.parse(s);
}
// 需要小心处理'obj'!
const obj = safeParse(someRandomString);
never
一些函数永远不会返回值:
function fail(msg: string): never {
throw new Error(msg);
}
never 类型代表从未观察到的值。在返回类型中,这意味着函数抛出异常或终止程序的执行。
当 TypeScript 确定联合中没有剩余元素时,也会出现 never。
function fn(x: string | number) {
if (typeof x === "string") {
// 干点啥
} else if (typeof x === "number") {
// 干点别的
} else {
x; // 拥有类型 'never'!
}
}
Function
全局类型 Function 描述了像 bind、call、apply 和其他在 JavaScript 中所有函数值上存在的属性。它还具有特殊的属性,即类型为 Function 的值总是可以被调用;这些调用返回 any:
function doSomething(f: Function) {
return f(1, 2, 3);
}
这是一个未指定类型的函数调用,通常最好避免使用,因为其返回类型 any 不安全。
如果你需要接受一个任意函数但并不打算调用它,那么 () => void 类型通常更安全。
剩余形参和实参 (Rest Parameters and Arguments)
剩余形参 (Rest Parameters)
除了使用可选参数或重载来创建可以接受多种固定参数数量的函数外,我们还可以使用剩余参数来定义一个可以接受无限数量参数的函数。
剩余参数出现在所有其他参数之后,并使用 ... 语法:
function multiply(n: number, ...m: number[]) {
return m.map((x) => n * x);
}
// 'a' 获得值 [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);
在 TypeScript 中,这些参数上的类型注解隐式地是 any[] 而不是 any,给出的任何类型注释必须是 Array<T> 或 T[] 的形式,或者是元组类型(我们稍后会学习)。
剩余实参 (Rest Arguments)
相反,我们可以使用扩展语法从可迭代对象(例如,数组)提供可变数量的参数。例如,数组的 push 方法接受任意数量的参数:
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);
请注意,一般来说,TypeScript 并不认为数组是不可变的。这可能会导致一些出人意料的行为:
// 推断的类型是 number[] -- "一个包含零个或更多数字的数组",并非特指两个数字
const args = [8, 5];
const angle = Math.atan2(...args);
// 报错:扩张参数必须具有元组类型或传递给 rest 参数。
这个情况的最佳解决方案依赖于你的代码,但一般来说,选取一个 const 上下文是最直接的解决方案。
// 推断为长度为2的元组
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);
使用 rest 参数可能需要在针对较旧的运行时开启 downlevelIteration。
参数解构
你可以使用参数解构来方便地将作为参数提供的对象解包到函数体中的一个或多个局部变量。在 JavaScript 中,它看起来像这样:
function sum({ a, b, c }) {
console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });
对象的类型注解在解构语法之后:
function sum({ a, b, c }: { a: number; b: number; c: number }) {
console.log(a + b + c);
}
这可能看起来有些冗长,但你也可以在这里使用命名类型:
// 与前例相同
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 的实现是有效的:
type voidFunc = () => void;
const f1: voidFunc = () => {
return true;
};
const f2: voidFunc = () => true;
const f3: voidFunc = function () {
return true;
};
当这些函数中的一个的返回值被赋给另一个变量时,它将保持 void 类型:
const v1 = f1();
const v2 = f2();
const v3 = f3();
这种行为的存在是为了使以下代码有效,即使 Array.prototype.push 返回一个数字,而 Array.prototype.forEach 方法期望一个返回类型为 void 的函数。
const src = [1, 2, 3];
const dst = [0];
src.forEach((el) => dst.push(el));
还有一个特殊情况需要注意,当一个字面函数定义具有 void 返回类型时,该函数不能返回任何东西。
function f2(): void {
// @ts-expect-error
return true;
}
const f3 = function (): void {
// @ts-expect-error
return true;
};
有关 void 的更多信息,请参阅这些其他文档条目: