深入理解 TypeScript
请支持本书:购买捐赠
(广告,请勿屏蔽。)

20 类型化函数



本章探讨 TypeScript 中函数的静态类型。

  在本章中,“函数”指的是“函数、方法或构造函数”

在本章中,关于函数所说的大多数内容(尤其是参数处理方面)也适用于方法和构造函数。

20.1 定义静态类型函数

20.1.1 函数声明

以下是在 TypeScript 中的函数声明示例

function repeat1(str: string, times: number): string { // (A)
  return str.repeat(times);
}
assert.equal(
  repeat1('*', 5), '*****');

20.1.2 箭头函数

repeat1() 的箭头函数版本如下所示

const repeat2 = (str: string, times: number): string => {
  return str.repeat(times);
};

在这种情况下,我们也可以使用表达式体

const repeat3 = (str: string, times: number): string =>
  str.repeat(times);

20.2 函数类型

20.2.1 函数类型签名

我们可以通过函数类型签名来定义函数的类型

type Repeat = (str: string, times: number) => string;

这种函数类型的名称是 Repeat。 除其他外,它匹配所有具有以下特征的函数

此类型匹配更多函数。 我们将在本章后面探讨赋值兼容性规则时,了解哪些函数与其匹配。

20.2.2 具有调用签名的接口

我们也可以使用接口来定义函数类型

interface Repeat {
  (str: string, times: number): string; // (A)
}

注意

一方面,接口更加冗长。 另一方面,它们允许我们指定函数的属性(这种情况很少见,但确实会发生)

interface Incrementor1 {
  (x: number): number;
  increment: number;
}

我们也可以通过函数签名类型和对象字面量类型的交集类型 (&) 来指定属性

type Incrementor2 =
  (x: number) => number
  & { increment: number }
;

20.2.3 检查可调用值是否匹配函数类型

例如,考虑以下情况:一个库导出以下函数类型。

type StringPredicate = (str: string) => boolean;

我们想定义一个类型与 StringPredicate 兼容的函数。 我们想立即检查是否确实如此(而不是在第一次使用它时才发现)。

20.2.3.1 检查箭头函数

如果我们使用 const 声明一个变量,我们可以通过类型注释来执行检查

const pred1: StringPredicate = (str) => str.length > 0;

请注意,我们不需要指定参数 str 的类型,因为 TypeScript 可以使用 StringPredicate 来推断它。

20.2.3.2 检查函数声明(简单)

检查函数声明更加复杂

function pred2(str: string): boolean {
  return str.length > 0;
}

// Assign the function to a type-annotated variable
const pred2ImplementsStringPredicate: StringPredicate = pred2;
20.2.3.3 检查函数声明(复杂)

以下解决方案稍微有点过头了(也就是说,如果您不完全理解它,也不用担心),但它展示了一些高级功能

function pred3(...[str]: Parameters<StringPredicate>)
  : ReturnType<StringPredicate> {
    return str.length > 0;
  }

20.3 参数

20.3.1 何时必须对参数进行类型注释?

回顾:如果启用了 --noImplicitAny--strict 会启用它),则每个参数的类型必须是可推断的或显式指定的。

在以下示例中,TypeScript 无法推断 str 的类型,我们必须指定它

function twice(str: string) {
  return str + str;
}

在 A 行中,TypeScript 可以使用类型 StringMapFunction 来推断 str 的类型,我们不需要添加类型注释

type StringMapFunction = (str: string) => string;
const twice: StringMapFunction = (str) => str + str; // (A)

在这里,TypeScript 可以使用 .map() 的类型来推断 str 的类型

assert.deepEqual(
  ['a', 'b', 'c'].map((str) => str + str),
  ['aa', 'bb', 'cc']);

这是 .map() 的类型

interface Array<T> {
  map<U>(
    callbackfn: (value: T, index: number, array: T[]) => U,
    thisArg?: any
  ): U[];
  // ···
}

20.3.2 可选参数

在本节中,我们将介绍几种允许省略参数的方法。

20.3.2.1 可选参数:str?: string

如果我们在参数名称后面加上一个问号,则该参数将变为可选参数,并且可以在调用函数时省略

function trim1(str?: string): string {
  // Internal type of str:
  // %inferred-type: string | undefined
  str;

  if (str === undefined) {
    return '';
  }
  return str.trim();
}

// External type of trim1:
// %inferred-type: (str?: string | undefined) => string
trim1;

以下是调用 trim1() 的方式

assert.equal(
  trim1('\n  abc \t'), 'abc');

assert.equal(
  trim1(), '');

// `undefined` is equivalent to omitting the parameter
assert.equal(
  trim1(undefined), '');
20.3.2.2 联合类型:str: undefined|string

在外部,trim1() 的参数 str 的类型为 string|undefined。 因此,trim1() 在大多数情况下等效于以下函数。

function trim2(str: undefined|string): string {
  // Internal type of str:
  // %inferred-type: string | undefined
  str;

  if (str === undefined) {
    return '';
  }
  return str.trim();
}

// External type of trim2:
// %inferred-type: (str: string | undefined) => string
trim2;

trim2()trim1() 的唯一区别是,在函数调用中不能省略该参数(A 行)。 换句话说:当省略类型为 undefined|T 的参数时,我们必须明确说明。

assert.equal(
  trim2('\n  abc \t'), 'abc');

// @ts-expect-error: Expected 1 arguments, but got 0. (2554)
trim2(); // (A)

assert.equal(
  trim2(undefined), ''); // OK!
20.3.2.3 参数默认值:str = ''

如果我们为 str 指定了参数默认值,则不需要提供类型注释,因为 TypeScript 可以推断出类型

function trim3(str = ''): string {
  // Internal type of str:
  // %inferred-type: string
  str;

  return str.trim();
}

// External type of trim2:
// %inferred-type: (str?: string) => string
trim3;

请注意,str 的内部类型为 string,因为默认值确保它永远不会是 undefined

让我们调用 trim3()

assert.equal(
  trim3('\n  abc \t'), 'abc');

// Omitting the parameter triggers the parameter default value:
assert.equal(
  trim3(), '');

// `undefined` is allowed and triggers the parameter default value:
assert.equal(
  trim3(undefined), '');
20.3.2.4 参数默认值加类型注释

我们也可以同时指定类型和默认值

function trim4(str: string = ''): string {
  return str.trim();
}

20.3.3 剩余参数

20.3.3.1 具有数组类型的剩余参数

剩余参数将所有剩余参数收集到一个数组中。 因此,它的静态类型通常是一个数组。 在以下示例中,parts 是一个剩余参数

function join(separator: string, ...parts: string[]) {
  return parts.join(separator);
}
assert.equal(
  join('-', 'state', 'of', 'the', 'art'),
  'state-of-the-art');
20.3.3.2 具有元组类型的剩余参数

下一个示例演示了两个功能

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

repeat1() 等效于以下函数

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

20.3.4 命名参数

命名参数 是 JavaScript 中一种流行的模式,其中使用对象字面量为每个参数指定名称。 如下所示

assert.equal(
  padStart({str: '7', len: 3, fillStr: '0'}),
  '007');

在纯 JavaScript 中,函数可以使用解构来访问命名参数值。 但是,在 TypeScript 中,我们还必须为对象字面量指定一个类型,这会导致冗余

function padStart({ str, len, fillStr = ' ' } // (A)
  : { str: string, len: number, fillStr: string }) { // (B)
  return str.padStart(len, fillStr);
}

请注意,解构(包括 fillStr 的默认值)都发生在 A 行中,而 B 行专门用于 TypeScript。

可以使用单独的类型来代替我们在 B 行中使用的内联对象字面量类型。 但是,在大多数情况下,我更喜欢不这样做,因为它稍微违背了参数的本质,即每个函数的参数都是局部且唯一的。 如果你喜欢函数头部的东西少一些,那也没关系。

20.3.5 作为参数的 this(高级)

每个普通函数始终都有一个隐式参数 this,这使得它可以在对象中用作方法。 有时我们需要为 this 指定一个类型。 TypeScript 为此用例提供了专用语法:普通函数的参数之一可以命名为 this。 这样的参数只在编译时存在,在运行时消失。

例如,请考虑以下 DOM 事件源的接口(略微简化版本)

interface EventSource {
  addEventListener(
    type: string,
    listener: (this: EventSource, ev: Event) => any,
    options?: boolean | AddEventListenerOptions
  ): void;
  // ···
}

回调函数 listenerthis 始终是 EventSource 的实例。

下一个示例演示了 TypeScript 如何使用 this 参数提供的类型信息来检查 .call() 的第一个参数(A 行和 B 行)

function toIsoString(this: Date): string {
    return this.toISOString();
}

// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'Date'. (2345)
assert.throws(() => toIsoString.call('abc')); // (A) error

toIsoString.call(new Date()); // (B) OK

此外,我们不能将 toIsoString() 作为对象 obj 的方法调用,因为那样它的接收者就不是 Date 的实例

const obj = { toIsoString };
// @ts-expect-error: The 'this' context of type
// '{ toIsoString: (this: Date) => string; }' is not assignable to
// method's 'this' of type 'Date'. [...]
assert.throws(() => obj.toIsoString()); // error
obj.toIsoString.call(new Date()); // OK

20.4 重载(高级)

有时,单个类型签名不足以描述函数的工作方式。

20.4.1 重载函数声明

考虑函数 getFullName(),我们在以下示例中调用它(A 行和 B 行)

interface Customer {
  id: string;
  fullName: string;
}
const jane = {id: '1234', fullName: 'Jane Bond'};
const lars = {id: '5678', fullName: 'Lars Croft'};
const idToCustomer = new Map<string, Customer>([
  ['1234', jane],
  ['5678', lars],
]);

assert.equal(
  getFullName(idToCustomer, '1234'), 'Jane Bond'); // (A)

assert.equal(
  getFullName(lars), 'Lars Croft'); // (B)

我们如何实现 getFullName()? 以下实现适用于上一个示例中的两个函数调用

function getFullName(
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string {
  if (customerOrMap instanceof Map) {
    if (id === undefined) throw new Error();
    const customer = customerOrMap.get(id);
    if (customer === undefined) {
      throw new Error('Unknown ID: ' + id);
    }
    customerOrMap = customer;
  } else {
    if (id !== undefined) throw new Error();
  }
  return customerOrMap.fullName;
}

但是,使用此类型签名,在编译时合法的函数调用会在运行时产生错误

assert.throws(() => getFullName(idToCustomer)); // missing ID
assert.throws(() => getFullName(lars, '5678')); // ID not allowed

以下代码修复了这些问题

function getFullName(customerOrMap: Customer): string; // (A)
function getFullName( // (B)
  customerOrMap: Map<string, Customer>, id: string): string;
function getFullName( // (C)
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string {
  // ···
}

// @ts-expect-error: Argument of type 'Map<string, Customer>' is not
// assignable to parameter of type 'Customer'. [...]
getFullName(idToCustomer); // missing ID

// @ts-expect-error: Argument of type '{ id: string; fullName: string; }'
// is not assignable to parameter of type 'Map<string, Customer>'.
// [...]
getFullName(lars, '5678'); // ID not allowed

这里发生了什么? getFullName() 的类型签名被重载了

我的建议是,只有在无法避免的情况下才使用重载。 一种替代方法是将重载函数拆分为多个具有不同名称的函数,例如

20.4.2 通过接口重载

在接口中,我们可以有多个不同的调用签名。 这使我们能够在以下示例中使用接口 GetFullName 进行重载

interface GetFullName {
  (customerOrMap: Customer): string;
  (customerOrMap: Map<string, Customer>, id: string): string;
}

const getFullName: GetFullName = (
  customerOrMap: Customer | Map<string, Customer>,
  id?: string
): string => {
  if (customerOrMap instanceof Map) {
    if (id === undefined) throw new Error();
    const customer = customerOrMap.get(id);
    if (customer === undefined) {
      throw new Error('Unknown ID: ' + id);
    }
    customerOrMap = customer;
  } else {
    if (id !== undefined) throw new Error();
  }
  return customerOrMap.fullName;
}

20.4.3 字符串参数重载(事件处理等)

在下一个示例中,我们重载并使用字符串字面量类型(例如 'click')。 这允许我们根据参数 type 的值更改参数 listener 的类型

function addEventListener(elem: HTMLElement, type: 'click',
  listener: (event: MouseEvent) => void): void;
function addEventListener(elem: HTMLElement, type: 'keypress',
  listener: (event: KeyboardEvent) => void): void;
function addEventListener(elem: HTMLElement, type: string,  // (A)
  listener: (event: any) => void): void {
    elem.addEventListener(type, listener); // (B)
  }

在这种情况下,要正确获取实现的类型(从 A 行开始)相对困难,以便主体中的语句(B 行)能够正常工作。 作为最后的手段,我们始终可以使用类型 any

20.4.4 重载方法

20.4.4.1 重载具体方法

下一个示例演示了方法的重载:方法 .add() 被重载。

class StringBuilder {
  #data = '';

  add(num: number): this;
  add(bool: boolean): this;
  add(str: string): this;
  add(value: any): this {
    this.#data += String(value);
    return this;
  }

  toString() {
    return this.#data;
  }
}

const sb = new StringBuilder();
sb
  .add('I can see ')
  .add(3)
  .add(' monkeys!')
;
assert.equal(
  sb.toString(), 'I can see 3 monkeys!')
20.4.4.2 重载接口方法

Array.from() 的类型定义是重载接口方法的一个示例

interface ArrayConstructor {
  from<T>(arrayLike: ArrayLike<T>): T[];
  from<T, U>(
    arrayLike: ArrayLike<T>,
    mapfn: (v: T, k: number) => U,
    thisArg?: any
  ): U[];
}

20.5 赋值兼容性(高级)

在本节中,我们将研究可赋值性的类型兼容性规则:Src 类型的函数是否可以传输到 Trg 类型的存储位置(变量、对象属性、参数等)?

了解可赋值性有助于我们回答以下问题,例如

20.5.1 可赋值性规则

在本小节中,我们将研究可赋值性的一般规则(包括函数的规则)。在下一小节中,我们将探讨这些规则对函数的意义。

如果满足以下条件之一,则类型 Src 可赋值 给类型 Trg

20.5.2 函数赋值规则的影响

在本小节中,我们将了解赋值规则对以下两个函数 targetFuncsourceFunc 的意义

const targetFunc: Trg = sourceFunc;
20.5.2.1 参数和结果的类型

示例

const trg1: (x: RegExp) => Object = (x: Object) => /abc/;

以下示例演示了如果目标返回类型是 void,则源返回类型无关紧要。为什么呢?在 TypeScript 中,void 结果总是被忽略。

const trg2: () => void = () => new Date();
20.5.2.2 参数数量

源的参数数量不得超过目标

// @ts-expect-error: Type '(x: string) => string' is not assignable to
// type '() => string'. (2322)
const trg3: () => string = (x: string) => 'abc';

源的参数数量可以少于目标

const trg4: (x: string) => string = () => 'abc';

为什么呢?目标指定了对源的期望:它必须接受参数 x。它确实如此(但它忽略了它)。这种允许性使得

['a', 'b'].map(x => x + x)

.map() 的回调函数只有 .map() 类型签名中提到的三个参数中的一个

map<U>(
  callback: (value: T, index: number, array: T[]) => U,
  thisArg?: any
): U[];

20.6 本章的进一步阅读和来源