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

22 Typ-Schutzfunktionen und Assertionsfunktionen



In TypeScript kann ein Wert einen Typ haben, der für bestimmte Operationen zu allgemein ist – zum Beispiel ein Union-Typ. Dieses Kapitel beantwortet folgende Fragen

22.1 Wann sind statische Typen zu allgemein?

Um zu sehen, wie ein statischer Typ zu allgemein sein kann, betrachten wir die folgende Funktion getScore()

assert.equal(
  getScore('*****'), 5);
assert.equal(
  getScore(3), 3);

Das Grundgerüst von getScore() sieht wie folgt aus

function getScore(value: number|string): number {
  // ···
}

Im Inneren von getScore() wissen wir nicht, ob der Typ von value number oder string ist. Bevor wir das wissen, können wir mit value nicht wirklich arbeiten.

22.1.1 Verengung über if und Typ-Schutzfunktionen

Die Lösung besteht darin, den Typ von value zur Laufzeit über typeof zu überprüfen (Zeile A und Zeile B)

function getScore(value: number|string): number {
  if (typeof value === 'number') { // (A)
    // %inferred-type: number
    value;
    return value;
  }
  if (typeof value === 'string') { // (B)
    // %inferred-type: string
    value;
    return value.length;
  }
  throw new Error('Unsupported value: ' + value);
}

In diesem Kapitel interpretieren wir Typen als Mengen von Werten. (Weitere Informationen zu dieser und einer anderen Interpretation finden Sie in [Inhalt nicht enthalten].)

Innerhalb der then-Blöcke ab Zeile A und Zeile B ändert sich der statische Typ von value aufgrund der durchgeführten Prüfungen. Wir arbeiten nun mit Teilmengen des ursprünglichen Typs number|string. Diese Art der Verkleinerung eines Typs nennt man *Verengung*. Das Prüfen des Ergebnisses von typeof und ähnlichen Laufzeitoperationen nennt man *Typ-Schutzfunktionen*.

Beachten Sie, dass die Verengung den ursprünglichen Typ von value nicht ändert, sondern ihn nur spezifischer macht, je mehr Prüfungen wir durchlaufen.

22.1.2 Verengung über switch und eine Typ-Schutzfunktion

Die Verengung funktioniert auch, wenn wir switch anstelle von if verwenden

function getScore(value: number|string): number {
  switch (typeof value) {
    case 'number':
      // %inferred-type: number
      value;
      return value;
    case 'string':
      // %inferred-type: string
      value;
      return value.length;
    default:
      throw new Error('Unsupported value: ' + value);
  }
}

22.1.3 Weitere Fälle, in denen Typen zu allgemein sind

Dies sind weitere Beispiele für zu allgemeine Typen

Beachten Sie, dass diese Typen alle Union-Typen sind!

22.1.4 Der Typ unknown

Wenn ein Wert den Typ unknown hat, können wir fast nichts damit tun und müssen seinen Typ zuerst verengen (Zeile A)

function parseStringLiteral(stringLiteral: string): string {
  const result: unknown = JSON.parse(stringLiteral);
  if (typeof result === 'string') { // (A)
    return result;
  }
  throw new Error('Not a string literal: ' + stringLiteral);
}

Mit anderen Worten: Der Typ unknown ist zu allgemein und wir müssen ihn verengen. In gewisser Weise ist unknown auch ein Union-Typ (die Union aller Typen).

22.2 Verengung über eingebaute Typ-Schutzfunktionen

Wie wir gesehen haben, ist eine Typ-Schutzfunktion eine Operation, die entweder true oder false zurückgibt – je nachdem, ob ihr Operand zur Laufzeit bestimmte Kriterien erfüllt. Die Typinferenz von TypeScript unterstützt Typ-Schutzfunktionen, indem sie den statischen Typ eines Operanden verengt, wenn das Ergebnis true ist.

22.2.1 Strikte Gleichheit (===)

Strikte Gleichheit funktioniert als Typ-Schutzfunktion

function func(value: unknown) {
  if (value === 'abc') {
    // %inferred-type: "abc"
    value;
  }
}

Bei einigen Union-Typen können wir === verwenden, um zwischen ihren Komponenten zu unterscheiden

interface Book {
  title: null | string;
  isbn: string;
}

function getTitle(book: Book) {
  if (book.title === null) {
    // %inferred-type: null
    book.title;
    return '(Untitled)';
  } else {
    // %inferred-type: string
    book.title;
    return book.title;
  }
}

Die Verwendung von === zum Einbeziehen und !=== zum Ausschließen einer Komponente eines Union-Typs funktioniert nur, wenn diese Komponente ein Singleton-Typ ist (eine Menge mit einem Mitglied). Der Typ null ist ein Singleton-Typ. Sein einziges Mitglied ist der Wert null.

22.2.2 typeof, instanceof, Array.isArray

Dies sind drei gängige eingebaute Typ-Schutzfunktionen

function func(value: Function|Date|number[]) {
  if (typeof value === 'function') {
    // %inferred-type: Function
    value;
  }

  if (value instanceof Date) {
    // %inferred-type: Date
    value;
  }

  if (Array.isArray(value)) {
    // %inferred-type: number[]
    value;
  }
}

Beachten Sie, wie der statische Typ von value innerhalb der then-Blöcke verengt wird.

22.2.3 Prüfung auf unterschiedliche Eigenschaften über den Operator in

Wenn der Operator in zur Prüfung auf unterschiedliche Eigenschaften verwendet wird, ist er eine Typ-Schutzfunktion

type FirstOrSecond =
  | {first: string}
  | {second: string};

function func(firstOrSecond: FirstOrSecond) {
  if ('second' in firstOrSecond) {
    // %inferred-type: { second: string; }
    firstOrSecond;
  }
}

Beachten Sie, dass die folgende Prüfung nicht funktioniert hätte

function func(firstOrSecond: FirstOrSecond) {
  // @ts-expect-error: Property 'second' does not exist on
  // type 'FirstOrSecond'. [...]
  if (firstOrSecond.second !== undefined) {
    // ···
  }
}

Das Problem in diesem Fall ist, dass wir ohne Verengung nicht auf die Eigenschaft .second eines Werts zugreifen können, dessen Typ FirstOrSecond ist.

22.2.3.1 Der Operator in verengt keine nicht-Union-Typen

Leider hilft uns in nur bei Union-Typen

function func(obj: object) {
  if ('name' in obj) {
    // %inferred-type: object
    obj;

    // @ts-expect-error: Property 'name' does not exist on type 'object'.
    obj.name;
  }
}

22.2.4 Prüfung des Werts einer gemeinsamen Eigenschaft (diskriminierte Unions)

Bei einer diskriminierten Union haben die Komponenten eines Union-Typs eine oder mehrere gemeinsame Eigenschaften, deren Werte für jede Komponente unterschiedlich sind. Solche Eigenschaften werden als Diskriminanten bezeichnet.

Die Prüfung des Werts eines Diskriminanten ist eine Typ-Schutzfunktion

type Teacher = { kind: 'Teacher', teacherId: string };
type Student = { kind: 'Student', studentId: string };
type Attendee = Teacher | Student;

function getId(attendee: Attendee) {
  switch (attendee.kind) {
    case 'Teacher':
      // %inferred-type: { kind: "Teacher"; teacherId: string; }
      attendee;
      return attendee.teacherId;
    case 'Student':
      // %inferred-type: { kind: "Student"; studentId: string; }
      attendee;
      return attendee.studentId;
    default:
      throw new Error();
  }
}

Im vorherigen Beispiel ist .kind ein Diskriminant: Jede Komponente des Union-Typs Attendee hat diese Eigenschaft mit einem eindeutigen Wert.

Eine if-Anweisung und Gleichheitsprüfungen funktionieren ähnlich wie eine switch-Anweisung

function getId(attendee: Attendee) {
  if (attendee.kind === 'Teacher') {
    // %inferred-type: { kind: "Teacher"; teacherId: string; }
    attendee;
    return attendee.teacherId;
  } else if (attendee.kind === 'Student') {
    // %inferred-type: { kind: "Student"; studentId: string; }
    attendee;
    return attendee.studentId;
  } else {
    throw new Error();
  }
}

22.2.5 Verengung von gepunkteten Namen

Wir können auch die Typen von Eigenschaften verengen (sogar von verschachtelten, auf die wir über eine Kette von Eigenschaftsnamen zugreifen)

type MyType = {
  prop?: number | string,
};
function func(arg: MyType) {
  if (typeof arg.prop === 'string') {
    // %inferred-type: string
    arg.prop; // (A)

    [].forEach((x) => {
      // %inferred-type: string | number | undefined
      arg.prop; // (B)
    });

    // %inferred-type: string
    arg.prop;

    arg = {};

    // %inferred-type: string | number | undefined
    arg.prop; // (C)
  }
}

Werfen wir einen Blick auf mehrere Stellen im vorherigen Code

22.2.6 Verengung von Array-Elementtypen

22.2.6.1 Die Array-Methode .every() verengt nicht

Wenn wir .every() verwenden, um zu prüfen, ob alle Array-Elemente nicht-nullish sind, verengt TypeScript den Typ von mixedValues nicht (Zeile A)

const mixedValues: ReadonlyArray<undefined|null|number> =
  [1, undefined, 2, null];

if (mixedValues.every(isNotNullish)) {
  // %inferred-type: readonly (number | null | undefined)[]
  mixedValues; // (A)
}

Beachten Sie, dass mixedValues schreibgeschützt sein muss. Wäre dies nicht der Fall, würde eine andere Referenz darauf uns statisch erlauben, null in mixedValues innerhalb der if-Anweisung einzufügen. Aber das würde den verengten Typ von mixedValues falsch machen.

Der vorherige Code verwendet die folgende *benutzerdefinierte Typ-Schutzfunktion* (mehr dazu bald)

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
  return value !== undefined && value !== null;
}

NonNullable<Union> (Zeile A) ist ein Utility-Typ, der die Typen undefined und null aus dem Union-Typ Union entfernt.

22.2.6.2 Die Array-Methode .filter() erzeugt Arrays mit verengten Typen

.filter() erzeugt Arrays mit verengten Typen (d.h., es verengt nicht wirklich bestehende Typen)

// %inferred-type: (number | null | undefined)[]
const mixedValues = [1, undefined, 2, null];

// %inferred-type: number[]
const numbers = mixedValues.filter(isNotNullish);

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
  return value !== undefined && value !== null;
}

Leider müssen wir direkt eine Typ-Schutzfunktions-Funktion verwenden – eine Pfeilfunktion mit einer Typ-Schutzfunktion reicht nicht aus

// %inferred-type: (number | null | undefined)[]
const stillMixed1 = mixedValues.filter(
  x => x !== undefined && x !== null);

// %inferred-type: (number | null | undefined)[]
const stillMixed2 = mixedValues.filter(
  x => typeof x === 'number');

22.3 Benutzerdefinierte Typ-Schutzfunktionen

TypeScript erlaubt uns, eigene Typ-Schutzfunktionen zu definieren – zum Beispiel

function isFunction(value: unknown): value is Function {
  return typeof value === 'function';
}

Der Rückgabetyp value is Function ist ein Typ-Prädikat. Er ist Teil der Typ-Signatur von isFunction()

// %inferred-type: (value: unknown) => value is Function
isFunction;

Eine benutzerdefinierte Typ-Schutzfunktion muss immer Booleans zurückgeben. Wenn isFunction(x) true zurückgibt, verengt TypeScript den Typ des tatsächlichen Arguments x zu Function

function func(arg: unknown) {
  if (isFunction(arg)) {
    // %inferred-type: Function
    arg; // type is narrowed
  }
}

Beachten Sie, dass TypeScript sich nicht darum kümmert, wie wir das Ergebnis einer benutzerdefinierten Typ-Schutzfunktion berechnen. Das gibt uns viel Freiheit bei den verwendeten Prüfungen. So hätten wir zum Beispiel isFunction() wie folgt implementieren können

function isFunction(value: any): value is Function {
  try {
    value(); // (A)
    return true;
  } catch {
    return false;
  }
}

Leider müssen wir den Typ any für den Parameter value verwenden, da der Typ unknown den Funktionsaufruf in Zeile A nicht zulässt.

22.3.1 Beispiel einer benutzerdefinierten Typ-Schutzfunktion: isArrayWithInstancesOf()

/**
 * This type guard for Arrays works similarly to `Array.isArray()`,
 * but also checks if all Array elements are instances of `T`.
 * As a consequence, the type of `arr` is narrowed to `Array<T>`
 * if this function returns `true`.
 * 
 * Warning: This type guard can make code unsafe – for example:
 * We could use another reference to `arr` to add an element whose
 * type is not `T`. Then `arr` doesn’t have the type `Array<T>`
 * anymore.
 */
function isArrayWithInstancesOf<T>(
  arr: any, Class: new (...args: any[])=>T)
  : arr is Array<T>
{
  if (!Array.isArray(arr)) {
    return false;
  }
  if (!arr.every(elem => elem instanceof Class)) {
    return false;
  }

  // %inferred-type: any[]
  arr; // (A)

  return true;
}

In Zeile A sehen wir, dass der abgeleitete Typ von arr nicht Array<T> ist, aber unsere Prüfungen haben sichergestellt, dass es das derzeit ist. Deshalb können wir true zurückgeben. TypeScript vertraut uns und verengt zu Array<T>, wenn wir isArrayWithInstancesOf() verwenden

const value: unknown = {};
if (isArrayWithInstancesOf(value, RegExp)) {
  // %inferred-type: RegExp[]
  value;
}

22.3.2 Beispiel einer benutzerdefinierten Typ-Schutzfunktion: isTypeof()

22.3.2.1 Ein erster Versuch

Dies ist ein erster Versuch, typeof in TypeScript zu implementieren

/**
 * An implementation of the `typeof` operator.
 */
function isTypeof<T>(value: unknown, prim: T): value is T {
  if (prim === null) {
    return value === null;
  }
  return value !== null && (typeof prim) === (typeof value);
}

Idealerweise könnten wir den erwarteten Typ von value über einen String angeben (d.h. eines der Ergebnisse von typeof). Dann müssten wir jedoch den Typ T aus diesem String ableiten, und es ist nicht sofort ersichtlich, wie das geht (es gibt einen Weg, wie wir bald sehen werden). Als Workaround geben wir T über ein Mitglied prim von T an

const value: unknown = {};
if (isTypeof(value, 123)) {
  // %inferred-type: number
  value;
}
22.3.2.2 Verwendung von Überladung

Eine bessere Lösung ist die Verwendung von Überladung (mehrere Fälle sind weggelassen)

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof(value: any, typeString: 'boolean'): value is boolean;
function isTypeof(value: any, typeString: 'number'): value is number;
function isTypeof(value: any, typeString: 'string'): value is string;
function isTypeof(value: any, typeString: string): boolean {
  return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'boolean')) {
  // %inferred-type: boolean
  value;
}

(Dieser Ansatz ist eine Idee von Nick Fisher.)

22.3.2.3 Verwendung einer Schnittstelle als Typ-Map

Eine Alternative ist die Verwendung einer Schnittstelle als Map von Strings zu Typen (mehrere Fälle sind weggelassen)

interface TypeMap {
  boolean: boolean;
  number: number;
  string: string;
}

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof<T extends keyof TypeMap>(value: any, typeString: T)
: value is TypeMap[T] {
  return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'string')) {
  // %inferred-type: string
  value;
}

(Dieser Ansatz ist eine Idee von Ran Lottem.)

22.4 Assertionsfunktionen

Eine Assertionsfunktion prüft, ob ihr Parameter bestimmte Kriterien erfüllt, und löst eine Exception aus, wenn dies nicht der Fall ist. Eine von vielen Sprachen unterstützte Assertionsfunktion ist beispielsweise assert(). assert(cond) löst eine Exception aus, wenn die boolesche Bedingung cond false ist.

Unter Node.js wird assert() über das eingebaute Modul assert unterstützt. Der folgende Code verwendet es in Zeile A

import assert from 'assert';
function removeFilenameExtension(filename: string) {
  const dotIndex = filename.lastIndexOf('.');
  assert(dotIndex >= 0); // (A)
  return filename.slice(0, dotIndex);
}

22.4.1 TypeScript-Unterstützung für Assertionsfunktionen

Die Typinferenz von TypeScript bietet spezielle Unterstützung für Assertionsfunktionen, wenn wir solche Funktionen mit Assertion-Signaturen als Rückgabetypen kennzeichnen. Bezüglich dessen, wie und was wir von einer Funktion zurückgeben können, ist eine Assertion-Signatur äquivalent zu void. Sie löst jedoch zusätzlich eine Verengung aus.

Es gibt zwei Arten von Assertion-Signaturen

22.4.2 Bestätigung eines booleschen Arguments: asserts «cond»

Im folgenden Beispiel besagt die Assertion-Signatur asserts condition, dass der Parameter condition true sein muss. Andernfalls wird eine Exception ausgelöst.

function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error();
  }
}

So bewirkt assertTrue() eine Verengung

function func(value: unknown) {
  assertTrue(value instanceof Set);

  // %inferred-type: Set<any>
  value;
}

Wir verwenden das Argument value instanceof Set ähnlich wie eine Typ-Schutzfunktion, aber anstatt einen Teil einer bedingten Anweisung zu überspringen, löst false eine Exception aus.

22.4.3 Bestätigung des Typs eines Arguments: asserts «arg» is «type»

Im folgenden Beispiel besagt die Assertion-Signatur asserts value is number, dass der Parameter value den Typ number haben muss. Andernfalls wird eine Exception ausgelöst.

function assertIsNumber(value: any): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
}

Diesmal verengt der Aufruf der Assertionsfunktion den Typ ihres Arguments

function func(value: unknown) {
  assertIsNumber(value);

  // %inferred-type: number
  value;
}
22.4.3.1 Beispiel-Assertionsfunktion: Hinzufügen von Eigenschaften zu einem Objekt

Die Funktion addXY() fügt bestehenden Objekten Eigenschaften hinzu und aktualisiert deren Typen entsprechend

function addXY<T>(obj: T, x: number, y: number)
: asserts obj is (T & { x: number, y: number }) {
  // Adding properties via = would be more complicated...
  Object.assign(obj, {x, y});
}

const obj = { color: 'green' };
addXY(obj, 9, 4);

// %inferred-type: { color: string; } & { x: number; y: number; }
obj;

Ein Schnittstellentyp S & T hat die Eigenschaften des Typs S und des Typs T.

22.5 Schnellreferenz: Benutzerdefinierte Typ-Schutzfunktionen und Assertionsfunktionen

22.5.1 Benutzerdefinierte Typ-Schutzfunktionen

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

22.5.2 Assertionsfunktionen

22.5.2.1 Assertion-Signatur: asserts «cond»
function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error(); // assertion error
  }
}
22.5.2.2 Assertion-Signatur: asserts «arg» is «type»
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(); // assertion error
  }
}

22.6 Alternativen zu Assertionsfunktionen

22.6.1 Technik: Erzwingende Konvertierung

Eine Assertionsfunktion verengt den Typ eines bestehenden Werts. Eine erzwungene Konvertierungsfunktion gibt einen bestehenden Wert mit einem neuen Typ zurück – zum Beispiel

function forceNumber(value: unknown): number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
  return value;
}

const value1a: unknown = 123;
// %inferred-type: number
const value1b = forceNumber(value1a);

const value2: unknown = 'abc';
assert.throws(() => forceNumber(value2));

Die entsprechende Assertionsfunktion sieht wie folgt aus

function assertIsNumber(value: unknown): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
}

const value1: unknown = 123;
assertIsNumber(value1);
// %inferred-type: number
value1;

const value2: unknown = 'abc';
assert.throws(() => assertIsNumber(value2));

Erzwungene Konvertierung ist eine vielseitige Technik mit Anwendungen, die über die von Assertionsfunktionen hinausgehen. Zum Beispiel können wir konvertieren

Weitere Informationen finden Sie in [Inhalt nicht enthalten].

22.6.2 Technik: Auslösen einer Exception

Betrachten Sie den folgenden Code

function getLengthOfValue(strMap: Map<string, string>, key: string)
: number {
  if (strMap.has(key)) {
    const value = strMap.get(key);

    // %inferred-type: string | undefined
    value; // before type check

    // We know that value can’t be `undefined`
    if (value === undefined) { // (A)
      throw new Error();
    }

    // %inferred-type: string
    value; // after type check

    return value.length;
  }
  return -1;
}

Anstelle der if-Anweisung, die in Zeile A beginnt, hätten wir auch eine Assertionsfunktion verwenden können

assertNotUndefined(value);

Das Auslösen einer Exception ist eine schnelle Alternative, wenn wir keine solche Funktion schreiben möchten. Ähnlich wie beim Aufruf einer Assertionsfunktion aktualisiert diese Technik auch den statischen Typ.

22.7 @hqoss/guards: Bibliothek mit Typ-Schutzfunktionen

Die Bibliothek @hqoss/guards bietet eine Sammlung von Typ-Schutzfunktionen für TypeScript – zum Beispiel