Breaking Changes
in TypeScript 4

TypeScript 4 im Visier
18
Mar

TypeScript 4.1: Neues aus der vierten Generation

TypeScript 4.1 ist da und bringt einige Erweiterungen und neue Sprachfeatures, aber keine bahnbrechenden Änderungen. Wie immer gilt bei TypeScript, dass nicht im klassischen Stil der semantischen Versionierung gearbeitet wird. Release 4.1 bietet Entwicklern also keine Pause hinsichtlich der möglicherweise nötigen Anpassungen am Code. Machen wir gemeinsam eine Reise von TypeScript 4.0 bis zur aktuellen Version 4.1. Was gibt es Neues in der vierten Generation?

Breaking Changes in TypeScript 4

Die vierte Generation kann jedoch nicht ohne Breaking Changes auskommen. TypeScript verwendet zwar ein Nummerierungsformat, das an die semantische Versionierung erinnert, folgt diesem Schema jedoch nicht. Jede neue Version kann Breaking Changes und große Featureänderungen mitbringen. Das TypeScript-Team begrenzt solche Eingriffe in die Sprache nicht auf die Versionen, die mit einer vollen Zahl nummeriert werden.

Zu den Breaking Changes gehören: Eine spezielle Deklarationsdatei lib.d.ts wird mit jeder Installation von TypeScript geliefert. Diese Datei enthält die Umgebungsdeklarationen für verschiedene DOM-Funktionen und -Typen. Hier wurde document.origin entfernt, das bisher für alte Versionen des Internet Explorers notwendig war. Alternativ steht self.origin zur Verfügung, das aber manuell ausgetauscht werden muss. Ebenfalls wurde Reflect.enumerate; entfernt. Zu den weiteren möglichen Anpassungen gibt es keine näheren Angaben, da diese von den automatisch erzeugten DOM-Typen abhängen.

TypeScript gibt jetzt immer einen Fehler aus, wenn eine Eigenschaft in einer abgeleiteten Klasse deklariert wird, die einen Getter oder Setter der Basisklasse überschreibt. Beim Verwenden der strictNullChecks-Option, wird ein Fehler geworfen, wenn mittels delete-Operator ein Objekt entfernt wird, das nicht vom Type any, unknown, never oder optional ist.

Ebenfalls als Breaking Change gelistet ist, dass resolve in Promises künftig mindestens einen Wert übergeben bekommen muss. Bisher waren die Parameter hier optional zu setzen. Das ist jetzt nicht mehr der Fall: Codestellen, die resolve ohne Parameter nutzen, geben in Zukunft einen Fehler aus.

Support für die neuen JSX Factories von React

JSX steht für JavaScript XML. Damit können wir HTML-Elemente in JavaScript für React schreiben, ohne die createElement()– und/oder appendChild()-Funktion dafür aufrufen zu müssen. Ab TypeScript 4.1 werden die Factory-Funktionen jsx und jsxs von React 17 unterstützt. Dafür stehen zwei neue Optionen für die jsx-Compiler-Option zur Verfügung: react-jsx und react-jsxdev. Diese Optionen sind für Produktions- bzw. Entwicklungskompilierungen vorgesehen. Oft können sich die Optionen von einem zum anderen erstrecken.

Mehr Performance und besserer Auto-Import-Support

Darüber hinaus verbessert TypeScript 4 die Bearbeitungsszenarien in Visual Studio Code und Visual Studio 2017 und 2019. Ein neuer Teilbearbeitungsmodus beim Start behebt langsame Startzeiten, insbesondere bei größeren Projekten. Eine intelligentere Funktion für den automatischen Import erledigt die zusätzliche Arbeit in Editorszenarien, um Pakete einzuschließen, die im Abhängigkeitsfeld von package.json aufgeführt sind. Informationen aus diesen Paketen werden verwendet, um die automatischen Importe zu verbessern, ohne die Typprüfung zu ändern.

Variadische Tupeltypen

Mit Tupeltypen können wir ein Array mit einer festen Anzahl von Elementen ausdrücken, deren Typ bekannt ist, aber nicht identisch sein muss. Beispielsweise möchten wir einen Wert als ein Paar aus einem String und einer Number darstellen, wie es in Listing 1 gezeigt wird.

let x: [string, number]; 
x = ["hello", 10]; // OK

// Initialize it incorrectly
x = [10, "hello"]; // Error

Als variadische Funktion bezeichnet man eine Funktion, deren Parameteranzahl nicht bereits in der Deklaration festgelegt ist. In TypeScript 4 können Tupeltypen für variadische Funktionen eingesetzt werden. Wie kann man sich diesen Mix jetzt nur genauer vorstellen? Betrachten wir hierbei Listing 2, das bereits ein gültiger TypeScript-Code ist, aber noch nicht optimal gestaltet ist.

Die concat-Funktion läuft ohne Probleme, aber wir verlieren hierbei die Typisierung und müssten das nachträglich manuell beheben, wenn wir an anderer Stelle genaue Werte erhalten möchten. Bisher war es unmöglich, eine solche Funktion vollständig zu Typisieren, um dieses Problem zu vermeiden.

function concat(
  numbers: number[],
  strings: string[]
): (string | number)[] {
  return [...numbers, ...strings];
}

let values = concat([1, 2], ["hi"]);
let value = values[1]; // infers string | number, but we *know* it's a number (2)

// TS does support accurate types for these values though:
let typedValues = concat([1, 2], ["hi"]) as [number, number, string];
let typedValue = typedValues[1] // => infers number, correctly

Listing 3 zeigt, dass wir mit der variadischen Deklaration der Tupeltypen die fehlende Typisierung gelöst bekommen. Sie erfolgt über die Generic-Deklaration, die an den spitzen Klammern zu erkennen ist. Wir können ein unbekanntes Tupel ([… T]) beschreiben oder diese verwenden, um teilweise bekannte Tupel ([string, … T, boolean, … U]) zu beschreiben. TypeScript kann hinterher die Typen für diese Platzhalter ableiten, sodass wir nur die Gesamtform des Tupels beschreiben und damit Code schreiben können, ohne von den spezifischen Details abhängig zu sein.

function concat<N extends number[], S extends string[]>(
  numbers: [...N],
  strings: [...S]
): [...N, ...S] {
  return [...numbers, ...strings];
}

let values = concat([1, 2], ["hi"]);
let value = values[1]; // => infers number
const val2 = values[1]; // => infers 2, not just any number

Beschriftete Tupelelemente

Das TypeScript-Team hat die Labeled Tuple Elements zur besseren Lesbarkeit eingeführt. Wenn wir einen Typen beschriften, müssen wir allen Typen ein Etikett geben, wie in Listing 4 zu sehen ist. Der wesentliche Vorteil ist nur beim Blick auf die Deklaration spürbar und erspart unnötige Codekommentare.

function foo(x: [first: string, second: number, ...rest: any[]]) {
  // a is string type
  // b is number type
  const [a, b] = x;
}

Implizite Typisierung von Eigenschaften

Eine besondere Stärke von TypeScript ist das implizierte Erkennen von Typen, das leider ebenfalls seine Grenzen hat. Wie zum Beispiel bei Funktionsparametern, die immer noch eine explizite Typisierung benötigen. Anders sieht es jetzt mit dem neuen Feature aus, der Property Type Inference. Wurde ein Wert für eine Klasseneigenschaft über den Konstruktor gesetzt, hat diese nicht automatisch den Typ erhalten. Hierbei musste man explizit den Typ dazu deklarieren. In Listing 5 sehen wir, dass jetzt TypeScript in der vierten Version vom Konstruktor aus eine implizite Typisierung der Eigenschaften durchführen kann.

class Foo {
  label; // Zeigt als Typ 'number | boolean' an.

  constructor(param: boolean) {
    if (param) {
      this.label = 123;
    } else {
      this.label = false;
    }
  }
}

Logische Zuweisungsoperatoren

TypeScript 4.0 hat bereits die logischen Zuweisungsoperatoren (Logical Assignment Operators) implementiert, die erst in ECMAScript 2021 als Standard veröffentlicht werden. Die Syntax kann auch rückwärts kompiliert werden, damit sie auch in älteren Browserumgebungen verwendet werden kann. In Listing 6 sind einige Beispiele aufgelistet. Heutzutage ist die letzte Option hier wahrscheinlich die nützlichste, es sei denn, wir behandeln ausschließlich Boolesche Werte. Diese Null-Koaleszenz-Zuweisung eignet sich perfekt für Standard- oder Fallback-Werte, bei denen a möglicherweise keinen Wert hat.

a ||= b
// Entspricht: a = a || b

a &&= b
// Entspricht: a = a && b

a ??= b
// Entspricht: a = a ?? b

Catch-Typ als unknown-Typ festlegen

Der unknown-Typ ist das typsichere Gegenstück zum any-Typ. Der Hauptunterschied zwischen den beiden Typen besteht darin, dass unknown weniger zulässig ist als any: Der unknown-Typ kann nur dem any-Typ und sich selbst zugewiesen werden. Dadurch sollen Anwender gezwungen sein, zurückgegebene Werte zu prüfen.

Bisher war der Fehlertyp aus einem catch-Block auf any begrenzt und konnte nicht direkt einem anderen Typen zugewiesen werden. Ab sofort können wir den Typ jedoch zur größeren Sicherheit auf den unknown-Typ ändern. Wir können ihn jedoch nicht direkt in einen anderen benutzerdefinierten Typ ändern. Das kann man nur im Funktionsblock vornehmen, wie Listing 7 zeigt.

function error() {
  try {
    throw new Error();
  } catch (e: unknown) {

    // Error
    // Object is of type unknown
    console.log(e.toUpperCase());
    
    if (typeof e === 'string') {
      // Das funktioniert jetzt
      console.log(e.toUpperCase());
    }
  }
}

@deprecated-Unterstützung

In JavaScript können wir über Codekommentare mit der @deprecated-Annotation aus JSDoc auf eine veraltete Implementierung hinweisen. Einige Codeeditoren können folglich bei der Autovervollständigung darauf hinweisen, dass die jeweilige Klasse oder Funktion möglichst nicht mehr zum Einsatz kommen soll. Ab sofort kann TypeScript beim Transpilieren ebenfalls darauf hinweisen und Codeeditoren wie Visual Studio Code weisen zusätzlich mit einer besseren Visualisierung darauf hin, wie in Abbildung 1 zu sehen ist. Mit TypeScript 4.1 wird gleichzeitig die @see-Annotation unterstützt.



Abb. 1: @deprecated-Unterstützung

Template Literal Types

Seit TypeScript 1.8 gibt es die Unterstützung von String Literal Types. Diese sind äußerst leistungsfähig, wenn wir streng typisierten APIs Sicherheit bieten möchten. Literaltypen wurden später erweitert, um auch numerische, boolesche und Enum-Literale zu unterstützen. Ein Beispiel zeigt Abbildung 2. Hier wird ein Beatles Type erzeugt, der nur die festgelegten Stringwerte unterstützt. In Zeile 4 wird ersichtlich, dass TypeScript darauf hinweist, dass dieser Stringwert nicht erlaubt ist. Einen weiteren Vorteil bietet die IntelliSense-Unterstützung, wie in Zeile 6 ersichtlich wird. Das sorgt für hervorragende Typsicherheit und Produktivität.



Abb. 2: String Literal Types

TypeScript 4.1 unterstützt jetzt Template Strings als Typen. Template Strings sind Stringsymbole, die über mehrere Zeilen gehende Zeichenketten sowie eingebettete JavaScript-Ausdrücke ermöglichen. Sie werden anstelle von doppelten bzw. einfachen Anführungszeichen von zwei Gravis-Akzentzeichen (`; französisch: Accent grave, englisch: Backtick) eingeschlossen. Sie können Platzhalter beinhalten, die durch das Dollarsymbol gefolgt von geschweiften Klammern gekennzeichnet sind (${expression}).

In Listing 8 wird der große Vorteil von Template Literal Types verdeutlicht. Aus zwei unterschiedlichen String Literal Types wird ein generischer Typ mit einem Template String erzeugt. Das erspart einiges an zusätzlichem Implementierungsaufwand und bietet zusätzlich eine hohe Flexibilität.

type Suit =
  "Hearts" | "Diamonds" |
  "Clubs" | "Spades";
type Rank =
  "Ace" | "Two" | "Three" | "Four" | "Five" |
  "Six" | "Seven" | "Eight" | "Nine" | "Ten" |
  "Jack" | "Queen" | "King";

type Card = `${Rank} of ${Suit}`;

const validCard: Card = "Three of Hearts";
const invalidCard: Card = "Three of Heart"; // Compiler Error

Eine erweiterte Form der Template Strings sind Tagged Template Strings. Mit ihnen kann die Ausgabe von Template Strings mit einer Funktion geändert werden. TypeScript 4.1 hat ebenfalls vier zusätzliche Tagged Helper implementiert: Capitalize, Uncapitalize, Uppercase und Lowercase. In Listing 9 sehen wir den Capitalize Tagged Helper im Einsatz.

interface Person {
  name: string;
  company: string;
  age: number;
}

// Erzeugt: "getName" | "getCompany" | "getAge"
type PersonAccessorNames = `get${Capitalize<keyof Person>}`;

Key Remapping in Mapped Types

Wir können auf dem Beispiel des Tagged Template Strings Helper aufbauen, indem wir es mit zugeordneten Typen kombinieren. Wenn wir im vorherigen Beispiel einen Typ für die Accessoren generieren möchten, können wir mit einem zugeordneten Typ beginnen (Listing 10). Durch keyof wird ein neuer Typ erstellt, wobei die ursprünglichen Datenelemente einer Funktion zugeordnet sind, die ihren Typ zurückgibt.

Mit der neuen as-Klausel können wir Funktionen wie Template Literal Types nutzen, um auf einfache Weise neue Eigenschaftsnamen basierend auf alten zu erstellen. Schlüssel können ebenfalls gefiltert werden.

interface Person {
  name: string;
  company: string;
  age: number;
}

type PersonAccessors = {
  [K in keyof Person as `get${Capitalize<K>}`]: () => Person[K];
}

/* PersonAccessors hat jetzt folgende Typ-Signatur:

{
  getName: () => string;
  getAge: () => number;
  getRegistered: () => boolean;
}

*/

Rekursive bedingte Typen

Ein weiterer Neuzugang in der aktuellen TypeScript Version 4.1 sind rekursive bedingte Typen. Rekursive bedingte Typen sind genau das, was der Name andeutet: conditional Types, die auf sich selbst verweisen. Sie bieten eine flexiblere Handhabung von bedingten Typen, indem sie sich innerhalb ihrer Zweige referenzieren können. Diese Funktion erleichtert das Schreiben rekursiver Aliasse. Listing 11 zeigt ein Beispiel für das Auspacken eines tief verschachtelten Promise mit Hilfe von async/await.

Es ist jedoch zu beachten, dass TypeScript mehr Zeit für die Typprüfung rekursiver Typen benötigt. Microsoft warnt, dass sie verantwortungsbewusst und sparsam eingesetzt werden sollten.

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

/// Wie `promise.then(...)`, aber genauer in Typen.
declare function customThen<T, U>(
  p: Promise<T>,
  onFulfilled: (value: Awaited<T>) => U
): Promise<Awaited<U>>;

Überprüfte indizierte Zugriffe

Mit Indexsignaturen in TypeScript 4.1 können wir auf beliebig benannte Eigenschaften zugreifen, wie in Listing 12 gezeigt wird. Hier sehen wir, dass eine Eigenschaft, auf die zugegriffen wird und die weder den Namenspfad noch die Namensberechtigungen hat, den Typ string oder number haben sollte.

Ein neues Transpiler Flag, —noUncheckedIndexedAccess, stellt einen Knoten bereit, auf dem jeder Eigenschaftszugriff (wie options.path) oder indizierter Zugriff (wie options [“foo bar baz”]) als potenziell undefiniert betrachtet wird. Das heißt, wenn wir im letzten Beispiel auf eine Eigenschaft wie options.path zugreifen müssen, müssen wir ihre Existenz überprüfen oder einen Nicht-Null-Assertion-Operator (das !-Zeichen als Postfix) verwenden.

Das —noUncheckedIndexedAccess Flag ist nützlich, um viele Fehler abzufangen, kann jedoch enorm viel Code verursachen. Aus diesem Grund wird es nicht automatisch durch das —strict Flag aktiviert.

interface Options {
  path: string;
  permissions: number;

  // Zusätzliche Eigenschaften werden von dieser Indexsignatur erfasst.
  [propertyName: string]: string | number;
}

function checkOptions(options: Options) {
  options.path; // string
  options.permissions; // number

  // Diese sind auch alle erlaubt!
  // Sie haben folgende Typen 'string | number'.
  options.yadda.toString();
  options["foo bar baz"].toString();
  options[Math.random()].toString();
}

Pfade ohne baseUrl

Vor TypeScript 4.1 musste der baseUrl-Parameter deklariert werden, um Pfade in der Datei tsconfig.json verwenden zu können. In der neuen Version ist es möglich, die Option path ohne baseUrl anzugeben. Das behebt das Problem, dass beim automatischen Import schlechte Pfade vorhanden sind.

checkJs impliziert jetzt allowJs

Wenn man ein JavaScript-Projekt hat und die checkJs-Option verwendet, um Fehler in .js-Dateien zu melden, sollte ebenfalls allowJs deklariert werden, um damit JavaScript-Dateien kompilieren zu können. Mit TypeScript 4.1 ist das nicht mehr der Fall. checkJs impliziert jetzt standardmäßig allowJs.

Fazit

Keines der gezeigten Sprachfeatures ist für sich allein genommen eine riesige Änderung, aber insgesamt wird so das Leben von TypeScript-Entwicklern verbessert, mit einigen großartigen Verbesserungen bezüglich der Typsicherheit und der Entwicklererfahrung insgesamt.

Aus der Community kommen sogar einige unterhaltsame Beispiele. Diese berechnen einen Typ, der die Lösung für ein Problem darstellt. Der Compiler wirft zwar einige Fehler, da zu viel Rekursion stattfindet, aber es sind eben nur Funprojekte zur Veranschaulichung der neuen Sprachfeatures.



Abb. 3: Der Sudoku Type Solver

Abbildung 3 zeigt einen Code, der einen Typ generiert, der ein Sudoku-Rätsel löst. In diesem Beispiel werden die String Literal Types “true” und “false” verwendet, um generische Typen zu erstellen, die als pseudobedingte Funktionen fungieren.



Abb.4: Der 12 Days of Christmas Type Solver

Abbildung 4 zeigt ein weiteres Spaßprojekt, das die Texte des klassischen Weihnachtsliedes „The 12 Days of Christmas“ generiert. Passend zum Artikel bietet der Autor einen Einstieg in JavaScript und TypeScript mit kostenlosen Videos auf YouTube. Viel Spaß beim Ansehen und mit TypeScript in der vierten Generation.

Ihr aktueller Zugang zur .NET- und Microsoft-Welt.
Der BASTA! Newsletter:

Behind the Tracks

.NET Framework & C#
Visual Studio, TFS, C# & mehr

Agile & DevOps
Best Practices & mehr

Web Development
Alle Wege führen ins Web

Data Access & Storage
Alles rund um´s Thema Data

HTML5 & JavaScript
Leichtegewichtig entwickeln

User Interface
Alles rund um UI- und UX-Aspekte

Microservices & APIs
Services, die sich über APIs via REST und JavaScript nutzen lassen

Security
Tools und Methoden für sicherere Applikationen

Cloud & Azure
Cloud-basierte & Native Apps