TypeScript meistern
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

20 Funktionen mit Typen versehen



Dieses Kapitel befasst sich mit der statischen Typisierung von Funktionen in TypeScript.

  In diesem Kapitel bedeutet „Funktion“ „Funktion, Methode oder Konstruktor“

In diesem Kapitel gilt für die meisten Aussagen über Funktionen (insbesondere in Bezug auf die Parameterbehandlung) auch für Methoden und Konstruktoren.

20.1 Statisch typisierte Funktionen definieren

20.1.1 Funktionsdeklarationen

Dies ist ein Beispiel für eine Funktionsdeklaration in TypeScript.

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

20.1.2 Pfeilfunktionen

Die Pfeilfunktionsversion von repeat1() sieht wie folgt aus:

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

In diesem Fall können wir auch einen Ausdruckskörper verwenden:

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

20.2 Typen für Funktionen

20.2.1 Funktionstyp-Signaturen

Wir können Typen für Funktionen über Funktionstyp-Signaturen definieren.

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

Der Name dieses Funktionstyps ist Repeat. Er stimmt unter anderem mit allen Funktionen überein, die Folgendes haben:

Dieser Typ stimmt mit mehr Funktionen überein. Welche das sind, erfahren wir, wenn wir später in diesem Kapitel die Regeln für die Zuweisbarkeit untersuchen.

20.2.2 Interfaces mit aufrufbaren Signaturen (Call Signatures)

Wir können auch Interfaces verwenden, um Funktionstypen zu definieren:

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

Hinweis

Einerseits sind Interfaces umständlicher. Andererseits ermöglichen sie es uns, Eigenschaften von Funktionen zu spezifizieren (was selten vorkommt, aber doch geschieht):

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

Wir können Eigenschaften auch über einen Schnittmengentyp (&) eines Funktionstyp-Signaturtyps und eines Objekt-Literal-Typs spezifizieren.

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

20.2.3 Prüfen, ob ein aufrufbarer Wert einem Funktionstyp entspricht

Betrachten wir als Beispiel folgendes Szenario: Eine Bibliothek exportiert den folgenden Funktionstyp.

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

Wir möchten eine Funktion definieren, deren Typ mit StringPredicate kompatibel ist. Und wir möchten sofort prüfen, ob dies tatsächlich der Fall ist (im Gegensatz zum späteren Feststellen, wenn wir sie zum ersten Mal verwenden).

20.2.3.1 Prüfen von Pfeilfunktionen

Wenn wir eine Variable mit const deklarieren, können wir die Prüfung über eine Typ-Annotation durchführen:

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

Beachten Sie, dass wir den Typ des Parameters str nicht angeben müssen, da TypeScript StringPredicate verwenden kann, um ihn zu inferrieren.

20.2.3.2 Prüfen von Funktionsdeklarationen (einfach)

Das Prüfen von Funktionsdeklarationen ist komplizierter.

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 Prüfen von Funktionsdeklarationen (extravagant)

Die folgende Lösung ist etwas übertrieben (d. h. machen Sie sich keine Sorgen, wenn Sie sie nicht vollständig verstehen), aber sie demonstriert mehrere fortgeschrittene Funktionen:

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

20.3 Parameter

20.3.1 Wann müssen Parameter typisiert werden?

Rückblick: Wenn --noImplicitAny eingeschaltet ist (--strict schaltet es ein), muss der Typ jedes Parameters entweder inferrierbar oder explizit angegeben sein.

Im folgenden Beispiel kann TypeScript den Typ von str nicht inferrieren, und wir müssen ihn angeben:

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

In Zeile A kann TypeScript den Typ StringMapFunction verwenden, um den Typ von str zu inferrieren, und wir müssen keine Typ-Annotation hinzufügen:

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

Hier kann TypeScript den Typ von .map() verwenden, um den Typ von str zu inferrieren:

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

Dies ist der Typ von .map():

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

20.3.2 Optionale Parameter

In diesem Abschnitt betrachten wir verschiedene Möglichkeiten, wie wir Parameter weglassen können.

20.3.2.1 Optionaler Parameter: str?: string

Wenn wir ein Fragezeichen nach dem Namen eines Parameters setzen, wird dieser Parameter optional und kann beim Aufrufen der Funktion weggelassen werden:

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;

So kann trim1() aufgerufen werden:

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 Union-Typ: str: undefined|string

Extern hat der Parameter str von trim1() den Typ string|undefined. Daher ist trim1() weitgehend äquivalent zu folgender Funktion:

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;

Der einzige Unterschied zwischen trim2() und trim1() ist, dass der Parameter bei Funktionsaufrufen nicht weggelassen werden kann (Zeile A). Mit anderen Worten: Wir müssen explizit sein, wenn wir einen Parameter weglassen, dessen Typ undefined|T ist.

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 Standardwert für Parameter: str = ''

Wenn wir einen Standardwert für den Parameter str angeben, benötigen wir keine Typ-Annotation, da TypeScript den Typ inferrieren kann:

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;

Beachten Sie, dass der interne Typ von str string ist, da der Standardwert sicherstellt, dass er nie undefined ist.

Rufen wir trim3() auf:

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 Standardwert für Parameter plus Typ-Annotation

Wir können auch sowohl einen Typ als auch einen Standardwert angeben:

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

20.3.3 Rest-Parameter

20.3.3.1 Rest-Parameter mit Array-Typen

Ein Rest-Parameter sammelt alle verbleibenden Parameter in einem Array. Daher ist sein statischer Typ normalerweise ein Array. Im folgenden Beispiel ist parts ein Rest-Parameter:

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-Parameter mit Tupel-Typen

Das nächste Beispiel demonstriert zwei Funktionen:

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

repeat1() ist äquivalent zur folgenden Funktion:

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

20.3.4 Benannte Parameter

Benannte Parameter sind ein beliebtes Muster in JavaScript, bei dem ein Objekt-Literal verwendet wird, um jedem Parameter einen Namen zu geben. Das sieht wie folgt aus:

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

In reinem JavaScript können Funktionen Destrukturierung verwenden, um auf benannte Parameterwerte zuzugreifen. In TypeScript müssen wir zusätzlich einen Typ für das Objekt-Literal angeben, was zu Redundanzen führt:

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

Beachten Sie, dass die Destrukturierung (einschließlich des Standardwerts für fillStr) komplett in Zeile A stattfindet, während Zeile B ausschließlich TypeScript betrifft.

Es ist möglich, einen separaten Typ zu definieren, anstatt den inline verwendeten Objekt-Literal-Typ aus Zeile B zu verwenden. In den meisten Fällen ziehe ich es jedoch vor, dies nicht zu tun, da es der Natur von Parametern, die lokal und pro Funktion eindeutig sind, leicht widerspricht. Wenn Sie weniger Dinge in Funktionsköpfen bevorzugen, ist das auch in Ordnung.

20.3.5 this als Parameter (Fortgeschrittene)

Jede normale Funktion hat immer den impliziten Parameter this – was ihr ermöglicht, als Methode in Objekten verwendet zu werden. Manchmal müssen wir einen Typ für this angeben. Für diesen Anwendungsfall gibt es eine reine TypeScript-Syntax: Einer der Parameter einer normalen Funktion kann den Namen this haben. Ein solcher Parameter existiert nur zur Kompilierzeit und verschwindet zur Laufzeit.

Betrachten wir als Beispiel die folgende Schnittstelle für DOM-Ereignisquellen (in einer leicht vereinfachten Version):

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

Das this des Callback listener ist immer eine Instanz von EventSource.

Das nächste Beispiel zeigt, dass TypeScript die Typinformationen des this-Parameters verwendet, um das erste Argument von .call() zu überprüfen (Zeile A und Zeile 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

Zusätzlich können wir toIsoString() nicht als Methode eines Objekts obj aufrufen, da sein Empfänger dann keine Instanz von Date ist:

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 Überladung (Fortgeschrittene)

Manchmal beschreibt eine einzelne Typ-Signatur nicht adäquat, wie eine Funktion funktioniert.

20.4.1 Überladung von Funktionsdeklarationen

Betrachten Sie die Funktion getFullName(), die wir im folgenden Beispiel aufrufen (Zeile A und Zeile 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)

Wie würden wir getFullName() implementieren? Die folgende Implementierung funktioniert für die beiden Funktionsaufrufe im vorherigen Beispiel:

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

Mit dieser Typ-Signatur sind jedoch Funktionsaufrufe zur Kompilierzeit legal, die zur Laufzeit zu Fehlern führen:

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

Der folgende Code behebt diese Probleme:

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

Was passiert hier? Die Typ-Signatur von getFullName() ist überladen:

Mein Rat ist, Überladung nur zu verwenden, wenn sie nicht vermieden werden kann. Eine Alternative ist, eine überladene Funktion in mehrere Funktionen mit unterschiedlichen Namen aufzuteilen – zum Beispiel:

20.4.2 Überladung über Interfaces

In Interfaces können wir mehrere, unterschiedliche aufrufbare Signaturen haben. Das ermöglicht uns, das Interface GetFullName für die Überladung im folgenden Beispiel zu verwenden:

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 Überladung basierend auf String-Parametern (Event-Handling etc.)

Im nächsten Beispiel überladen wir und verwenden String-Literal-Typen (wie 'click'). Das erlaubt uns, den Typ des Parameters listener je nach Wert des Parameters type zu ändern:

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

In diesem Fall ist es relativ schwierig, die Typen der Implementierung (beginnend in Zeile A) korrekt zu gestalten, sodass die Anweisung im Körper (Zeile B) funktioniert. Als letztes Mittel können wir immer den Typ any verwenden.

20.4.4 Überladung von Methoden

20.4.4.1 Überladung von konkreten Methoden

Das nächste Beispiel demonstriert die Überladung von Methoden: Methode .add() ist überladen.

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 Überladung von Interface-Methoden

Die Typdefinition für Array.from() ist ein Beispiel für eine überladene Interface-Methode:

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 Zuweisbarkeit (Fortgeschrittene)

In diesem Abschnitt betrachten wir die Typ-Kompatibilitätsregeln für die Zuweisbarkeit: Können Funktionen vom Typ Src auf Speicherorte (Variablen, Objek Eigenschaften, Parameter usw.) vom Typ Trg übertragen werden?

Das Verständnis der Zuweisbarkeit hilft uns, Fragen zu beantworten wie:

20.5.1 Die Regeln für Zuweisbarkeit

In diesem Unterabschnitt untersuchen wir allgemeine Regeln für die Zuweisbarkeit (einschließlich der Regeln für Funktionen). Im nächsten Unterabschnitt untersuchen wir, was diese Regeln für Funktionen bedeuten.

Ein Typ Src ist zuweisbar zu einem Typ Trg, wenn eine der folgenden Bedingungen erfüllt ist:

20.5.2 Konsequenzen der Zuweisungsregeln für Funktionen

In diesem Unterabschnitt betrachten wir, was die Zuweisungsregeln für die folgenden beiden Funktionen targetFunc und sourceFunc bedeuten:

const targetFunc: Trg = sourceFunc;
20.5.2.1 Typen von Parametern und Ergebnissen

Beispiel

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

Das folgende Beispiel zeigt, dass, wenn der Ziel-Rückgabetyp void ist, der Quell-Rückgabetyp keine Rolle spielt. Warum ist das so? void-Ergebnisse werden in TypeScript immer ignoriert.

const trg2: () => void = () => new Date();
20.5.2.2 Anzahl der Parameter

Die Quelle darf nicht mehr Parameter haben als das Ziel.

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

Die Quelle darf weniger Parameter haben als das Ziel.

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

Warum ist das so? Das Ziel gibt die Erwartungen für die Quelle vor: Sie muss den Parameter x akzeptieren. Was sie auch tut (aber ignoriert). Diese Erlaubnis ermöglicht:

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

Der Callback für .map() hat nur einen der drei Parameter, die in der Typ-Signatur von .map() erwähnt werden.

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

20.6 Weiterführende Lektüre und Quellen dieses Kapitels