TypeScript 函数类型

331 阅读10分钟

Snipaste_2023-07-01_12-37-51.png

函数类型表达式

描述函数的最简单方法是使用函数类型表达式。这些类型在语法上类似于箭头函数:

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  运算符调用。它们被称为构造函数,因为它们通常创建一个新对象。您可以通过在调用函数签名前面添加  new  关键字来编写构造函数签名:

type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

有些对象,例如 JavaScript 的  Date  对象,可以使用或不使用  new  进行调用。所以你可以任意组合同一类型的调用函数签名和构造函数签名:

interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): string;
}

泛型函数

某些函数,他输入的类型与输出的类型相关,或者两个输入的类型以某种方式相关。如考虑这样的函数:他返回数组第一个元素:

function firstElement(arr: any[]) {
  return arr[0];
}

这个函数可以正常使用,但不幸的是它的返回类型是  any 。如果函数返回数组元素的类型会更好。

在 TypeScript 中,当我们想要描述两个值之间的对应关系时,就会使用泛型。我们通过在函数签名中声明类型参数来做到这一点:

function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}

通过向该函数添加类型参数  Type  并在两个地方使用它,我们在函数的输入(数组)和输出(返回值)之间创建了联系。现在当我们调用它时,会出现一个更具体的类型:

// s is of type 'string'
const s = firstElement(["a", "b", "c"]);
// n is of type 'number'
const n = firstElement([1, 2, 3]);
// u is of type undefined
const u = firstElement([]);

类型推断

回看上个例子,我们没有在调用函数时指定 Type 的值,他是由 TypeScript 推断的。

我们也可以使用多个类型参数:

function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
  return arr.map(func);
}

// Parameter 'n' is of type 'string'
// 'parsed' is of type 'number[]'
const parsed = map(["1", "2", "3"], (n) => parseInt(n));

很厉害,在此示例中,TypeScript 可以推断  Input  类型参数的类型(给定的  string  数组)以及  Output  类型参数基于函数表达式的返回值 ( number )。

约束条件

有时,我们想让泛型参数的类型是某种类型的子集,可以使用 extends 来完成。

如我们编写一个返回两个值中较长元素的函数,为此,我们需要  length  属性:

function longest<Type extends { length: number }>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
  } else {
    return b;
  }
}

// longerArray is of type 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString is of type 'alice' | 'bob'
const longerString = longest("alice", "bob");
// Error! Numbers don't have a 'length' property
const notOK = longest(10, 100);

注意:这里没有写 longest 函数的返回类型,因为 TypeScript 自动推断出来了。

因为我们将  Type  限制为  { length: number } ,所以可以访问  a  和  b  的  .length  属性。如果没有类型约束,将无法访问这个属性。

longerArray  和  longerString  的类型是根据参数推断出来的。请记住,泛型就是将两个或多个具有相同类型的值关联起来!

最后,对  longest(10, 100)  的调用报错了,因为  number  类型没有  .length  属性。

使用约束条件

注意,以下是使用通用约束时的一个常见错误:

image.png

看起来这个函数没问题, Type  被限制为  { length: number } ,并且该函数返回  Type。但是问题在于该函数承诺返回与传入的对象相同类型的对象,而不仅仅是匹配约束的某个对象。如果这段代码是合法的,那么下面代码就是不合理的:

// 'arr' gets value { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
// and crashes here because arrays have
// a 'slice' method, but not the returned object!
console.log(arr.slice(0));

指定类型参数

TypeScript 通常可以在泛型调用中推断出预期的类型参数,但并非总是如此。例如您编写了一个函数来组合两个数组:

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}

如果使用类型不匹配的数组调用此函数会报错:

image.png

但如果你想特意结合数字和字符串数组,可以手动指定类型参数:

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 (good)
const a = firstElement1([1, 2, 3]);
// b: any (bad)
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);
}

filter2 增加了一个类型参数 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 arguments
  console.log(n.toFixed(3)); // 1 argument
}

在 TypeScript 中,我们可以使用  ?  将参数标记为可选:

function f(x?: number) {
  // ...
}
f(); // OK
f(10); // OK

虽然参数被指定为类型  number ,但  x  参数实际上是  number | undefined  类型,因为 JavaScript 中未指定的参数获取的值是 undefined。

当然,您还可以提供参数默认值(这时不需要写?了):

function f(x = 10) {
  // ...
}

现在,参数x将变为number  类型,没有 undefined 类型了

回调函数中的可选参数

在你了解可选参数后,很容易掉入一个在回调函数中使用的陷阱:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

使用者趋向于这样传入回调函数:

myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

但是在调用回调函数时,由于 index 是可选的,所以你可能没有传入 index:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // I don't feel like providing the index today
    callback(arr[i]);
  }
}

image.png

这样回调函数调用第二个参数时就会报错。

注意:在回调函数参数类型中,永远不要写可选参数,除非你真的想要参数可选。

函数重载

一些 JavaScript 函数可以以多种参数或多种类型进行调用。例如,编写一个函数来生成一个  Date ,它输入时间戳(一个参数)或月/日/年(三个参数)。

在 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);
// Error: No overload expects 2 arguments,
// but overloads do exist that expect either 1 or 3 arguments.
const d3 = makeDate(1, 3);

在上面的代码中,我们编写了两个重载函数签名:一个接受一个参数,另一个接受三个参数。请注意,只有前两个签名是重载签名。

第三个函数签名是一个具有兼容参数的函数实现,他被称为实现函数签名,实现函数签名不能直接调用,就像上面的示例 makeDate(1, 3) 会报错一样。

重载函数签名 vs 实现函数签名

如果单把实现函数签名拿出来当成普通函数签名,那 makeDate(1, 3) 就不会报错,但是在当前情况下,已经有了两个重载函数签名,它属于实现函数签名,实现函数签名没有实际意义,所以会报错。

image.png

如上只有一个重载函数签名,它有一个参数,所以没有参数调用时会报错。

注意:实现函数签名对外部不可见,所以当编写重载函数时,你应该始终在函数的实现之上有两个或多个重载签名。

实现函数签名还必须与重载函数签名的参数和返回值类型兼容,如下代码会报错:

image.png

image.png

编写好的重载

与泛型一样,使用函数重载时应遵循一些准则。它将使你的函数更容易调用、更容易理解、更容易实现。

有一个返回字符串或数组长度的函数:

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}

我们可以使用字符串或数组来调用它。但是,不能同时使用可能是字符串类型或数组类型的值来调用它,因为 TypeScript 只能把函数调用理解为单个重载:

image.png

那如何解决呢?我们看到两个函数重载有相同的参数个数和返回值类型,那么可以使用联合类型参数来实现而非函数重载:

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,它指向  user对象。你有可能想要控制 this,JavaScript 规范规定不能拥有名称为  this  的参数,因此 TypeScript 使用如下语法让您在函数中声明  this  的类型。

interface User {
  id: number;
  admin: boolean;
}
declare const getDB: () => DB;
// ---cut---
interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db = getDB();
const admins = db.filterUsers(function (this: User) {
  return this.admin;
});

这种模式在回调式 API 中很常见,外层函数控制何时调用您的回调函数。请注意,要使用  function  表达式而不是箭头函数,因为监听函数会绑定当时上下文的 this。

image.png

其他需要了解的类型

你还需要了解一些在使用函数类型时经常出现的其他类型。它们在函数上下文中很重要。

void

void  表示不返回值的函数返回类型。只要函数没有  return  语句,或者不从这些 return 语句返回任何显式值,就会被推断为 void

// The inferred return type is void
function noop() {
  return;
}

在 JavaScript 中,不返回任何值的函数将返回  undefined 。然而,在 TypeScript 中  void  和  undefined  并不是一回事。

再次提醒:void  与  undefined  不同。

unknown

unknown  类型代表任何值。这类似于  any  类型,但更安全,因为使用  unknown  类型的值执行任何操作都会报错:

image.png

这在描述函数参数类型时很有用,因为您可以让入参接受任何类型的值,而不用在函数体内的代码中包含  any  类型。

您还可以描述一个返回 unknown 类型的函数:

function safeParse(s: string): unknown {
  return JSON.parse(s);
}

// Need to be careful with '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") {
    // do something
  } else if (typeof x === "number") {
    // do something else
  } else {
    x; // has type 'never'!
  }
}

剩余参数

除了使用可选参数或重载来创建可以接受各种参数的函数之外,我们还可以使用剩余参数来定义接受无限数量参数的函数。

剩余参数在所有其他参数之后出现,并使用  ...  语法:

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);

扩展数组

我们可以使用扩展语法从可迭代对象(例如数组)提供可变数量的参数。例如,数组的  push  方法接受任意数量的参数:

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);

TypeScript 不认为数组是不可变的。这可能会导致一些意外行为:

image.png

这种问题的最佳解决方案在一定程度上取决于您的代码上下文,但通常  as const  是最直接的解决方案:

// Inferred as 2-length tuple
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);

参数解构

TypeScript 参数解构的对象类型注解如下:

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;

const f1: voidFunc = () => {
  return true;
};

const f2: voidFunc = () => true;

const f3: voidFunc = function () {
  return true;
};

当这些函数的返回值分配给变量时,变量的类型是 void:

// v1 是 void 类型
const v1 = f1();
// v2 是 void 类型
const v2 = f2();
// v3 是 void 类型
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  类型字面量时,该函数不得返回任何内容。

image.png