處理 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 Rest 參數

20.3.3.1 具有陣列型別的 Rest 參數

Rest 參數會將所有剩餘參數收集到陣列中。因此,它的靜態型別通常是陣列。在以下範例中,parts 是 Rest 參數

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 具有元組型別的 Rest 參數

下一個範例展示兩個功能

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() 的 callback 僅有一個參數,而 .map() 的類型簽章中提到了三個參數

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

20.6 進一步閱讀和本章來源