- 1. Präambel
- 2. Einführung in npm und Node.js
- 3. Theoretischer Hintergrund zu TypeScript (und JavaScript)
- 4. Tests schreiben
- 5. strict mode
- 6. Die ersten besonderen Typen: Union Types & Literal Types
- 7. Type- und Value-Welt
- 8. Generic Type alias
- 9. any @ts-ignore und @ts-expect-error
- 10. Exhaustivenes checks
- 11. Abschluss
Kurzzusammenfassung
TypeScript erobert die Welt und vielleicht auch dein Herz. In diesem Workshop bekommst du eine kleine Kostprobe davon, wie du selbst ein TypeScript Projekt aufsetzen kannst, was TypeScript so besonders macht und welche Tricks und Kniffe es gibt, den Schreib- und Wartungsaufwand in deinem TypeScript-Projekt so gering wie möglich zu halten.
Alles was du dafür brauchst, ist eine IDE, die TypeScript versteht (ich persönlich nutze VS Code, Alternativen wie die JetBrains-Produkte oder Atom gehen auch, da kann ich aber weniger Support geben) und eine Installation von einer halbwegs aktuellen Version von Node.js/npm.
JavaScript-Grundlagen können außerdem nicht schaden, sind aber nicht vorrausgesetzt MDN-JavaScript Guide.
Diese Unterlagen wurden so konzipiert, dass du dich auch selbständig durch den Workshop arbeiten kannst. In jeder Sektion wird zunächst etwas Wissen vermittelt, welches du benötigst, um die folgende Aufgabe zu lösen. Im \src
Order findest du zudem Lösungen zu den einzelnen Aufgaben.
TypeScript ist die meist-verwendete "Compile-to-JS" Programmiersprache. Das heißt, wir schreiben zwar TypeScript Code, dieser wird aber nie direkt ausgeführt, sondern immer erst zu JavaScript umgewandelt, um dann ausgeführt zu werden. Ziel von TypeScript ist es, statische Typisierung zur dynamischen Sprache JavaScript hinzuzufügen. TypeScript ist damit ein simpler Aufsatz auf JavaScript bis auf die Typannotationen, sieht TypeScript genauso aus wie JavaScript.
TypeScript macht aus JavaScript eine Programmiersprache, mit der auch größere komplexere Projekte implementiert werden können. Extrem viele Fehler, die bei JavaScript erst zu Laufzeit auftreten würden, findet der TypeScript Compiler bevor wir eine Zeile Code ausführen müssen. Damit sind dann auch größere Refactorings oder Funktionen wie "Find References" oder "Find implementations" möglich.
Diese Vorteile sind auch durch Community-Umfragen sichtbar: 93% der Nutzer:innen würden im nächsten Webprojekt wieder zu TypeScript greifen State of JS 2020 und die Stack Overflow Developer Survey platziert TypeScript hinter Rust und Clojure auf Platz 3 der meist geliebten Programmiersrpachen Loved vs. Dreaded
Wir wollen uns in diesem Workshop TypeScript erstmal isoliert anschauen, also ohne Framework, Bibliothek oder Browser. Dafür richten wir ein Node.js-Projekt ein, sodass wir den resultierenden JavaScript Code direkt auf dem Rechner ausführen können.
🎯 Ziel: Ein Node.js Projekt ist richtig eingerichtet und du kannst JavaScript Code auf deinem Rechner ausführen
🎓 Wissen: npm ist der Node Package Manager und ist damit das Analog zu nuget bzw. maven. Du wirst heute npm nutzen, um das Projekt aufzusetzen, Pakete zu installieren und Start-Skripte zu definieren.
🎯 Ziel: Wir sind sicher, dass Node.js und npm auf deinem Rechner installiert und eingerichtet ist.
- 💪 Öffne einen Terminal
- 💪 Führe
node --version
aus. Als Ergebnis solltest du eine Versionsnummer auf der Konsole erhalten hier sollte min. 12.X eingerichtet sein. - 💪 Führe
npm --version
aus. Auch hier sollte eine Versionsnummer erscheinen. (min. 6.X)
🎓 Wissen: Der Einstiegspunkt in jedes Node.js-Projekt ist immer die package.json
-Datei. Sie definiert Abhängigkeiten, relevante Entwicklungsskripte und weitere Metainformationen über das Projekt.
🎯 Ziel: Wir haben ein Projekt, wo wir Code ausführen und Pakete installieren können.
- 💪 Lege einen neuen Ordner für unser Spielprojekt an
- 💪 Öffne einen Terminal in diesem Ordner
- 💪 Führe
npm init --yes
aus. Dadurch wird eine neue Dateipackage.json
angelegt, die das Projekt beschreibt. Durch--yes
werden die defaults akzeptiert, die uns aktuell reichen. - 💪 Lege eine neue Datei an:
src/index.js
und schreibeconsole.log('Hello World');
in diese Datei - 💪 Führe über den Terminal
node src/index.js
aus. (Passe den Dateipfad an dein Betriebssystem an, unter Windows:src\index.js
)
💣 Problem: Auch wenn das bereits funktioniert, können andere Entwickler:innen nicht wissen, wie das Projekt gestartet wird. Deswegen nutzen wir npm-Skripte, um bestimmte Routinen für den Entwicklungsprozess festzuhalten.
🎯 Ziel: Ein npm Skript für das Starten des Projektes ist definiert
- 💪 Öffne die
package.json
-Datei. - 💪 Je nach npm Version ist in diesem JSON bereits ein Bereich
scripts
vorhanden, wenn nicht, definiere diesen und definiere ein neues Feld:start
. - 💪 In den Wert des Feldes legst du den String
"node src/index.js"
. Das Ergebnis sollte ungefähr so aussehen:
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC"
}
- 💪 Starte im Terminal
npm run start
, um unser start-Skript anzuwerfen.
💣 Problem: Wir haben jetzt ein lauffähiges Node.js Projekt. Node.js selbst kann aber nur JavaScript aussführen, wir wollen aber ja TypeScript schreiben. Im nächsten Schritt müssen wir also den TypeScript-Compiler konfigurieren.
🎓 Wissen: In einem npm-Projekt, sind alle Dependencies lokal installiert (im node_modules
Ordner). Vorteil davon ist, dass es keine Konflikte mit globalen Installationen geben kann und, dass der Source Code der Pakete direkt verfügbar und damit debug und veränderbar ist. Als Nachteil liegen Pakete dadurch pro Projekt einmal auf der Festplatte, wodurch viel Speicherplatz verbraucht wird.
🎓 Wissen: Zusätzlich zur package.json
benötigt ein TypeScript-Projekt noch eine weitere Konfigurationsdatei, die tsconfig.json
. Hier werden Konfiguriationen für den TypeScript-Compiler abgelegt.
🎯 Ziel: Der TypeScript-Compiler ist installiert und konfiguriert, sodass du TypeScript Code entwickeln und ausführen kannst.
- 💪 Bevor wir mit den Aufgaben anfangen, wollen wir unser Projekt mit git Versionieren. Führe dazu im Terminal
git init
aus. Füge zudem dennode_modules/
Ordner zu.gitignore
hinzu und commite den aktuellen Stand. - 💪 Führe im Terminal
npm i --save-dev typescript ts-node
.npm i
steht dafür fürinstall
,--save-dev
sagt npm, dass die Pakete nur für die Entwicklung unseres Projektes nötig sind, es sind keine Laufzeitabhängigkeiten. Die Einteilung in dependencies und devDependencies ist bei Applikationen aber eher Convention, nur bei der Entwicklung von Bibliotheken ist diese Trennung absolut wichtig. - 💪 Lege im Root deines Projektes eine neue Datei an:
tsconfig.json
und befülle sie mit folgendem Inhalt:
{
"compilerOptions": {
"moduleResolution": "Node",
"target": "ES2019",
"noEmit": true,
"esModuleInterop": true
}
}
Eine genaue Referenz über die verschiedenen Felder der tsconfig gibt es hier: https://www.typescriptlang.org/tsconfig
- 💪 Benenne die Datei
index.js
um inindex.ts
- 💪 Ändere das Start-Skript in der
package.json
innode -r ts-node/register ./src/index.ts
- 💪 Führe den Code aus mit
npm run start
und prüfe, ob das Programm noch ordentlich läuft.
🎓 Wissen: Zum Ausführen des TypeScript Codes verwenden wir ts-node. Dieses Tool wandelt beim Start der Applikation jeglichen TypeScript Code um in JavaScript und leitet den Code dann weiter an Node.js für die Ausführung.
💣 Problem: Immerhin können wir jetzt TypeScript-Code ausführen. ts-node prüft vor dem Ausführen auch, ob wir irgendwo Typfehler im Projekt haben, diese Prüfung wollen wir aber auch durchführen, ohne das Projekt selbst starten zu müssen.
- 💪 Definiere ein neues npm-Skript in der
package.json
mit dem Namentsc-watch
mit dem folgenden Inhalt"tsc --watch"
. Dadurch startet der TypeScript-Compiler und prüft bei jeder Änderung in einer Source Code Datei ob unser Projekt noch richtig kompiliert. Diesen Prozess lässt du bei der Entwicklung einfach im Terminal offen.
🎓 Wissen: Wie bereits erwähnt, ist TypeScript ein Aufsatz auf JavaScript, dementsprechend sieht unser Code fast genauso aus wie JavaScript, nur können an vielen Stellen Typangaben gemacht werden.
🎓 Wissen: TypeScript verfügt über eine ausgeprägte Inferenz-Logik. Der Compiler versaucht selbständig herauszufinden, welchen Typ unsere Variablen haben:
// TypeScript infers type 'number'
let x = 5;
// Type 'string' is not assignable to type 'number'.
x = "string";
🎓 Wissen: Die Inferenz funktioniert auch genauso bei Rückgabewerten von Funktionen.
// TypeScript infers return type 'number'
function randomNumber() {
// Determined by dice throw.
return 4;
}
// TypeScript infers type 'string'
let x = "test";
// Type 'number' is not assignable to type 'string'.
x = randomNumber();
🎓 Wissen: Die einzigen Stellen, wo wir Typen also selbst angeben müssen sind Funktionsparameter und die Fälle, in denen wir die Inferenz überschreiben wollen oder besonders explizit sein wollen.
// Long and complex function
// signature should directly show return type string
function sum(a: number, b: string): string {
// ...
return b.repeat(a);
}
// Unnecessary type annotation
let x: number = 5;
🎓 Wissen: TypeScript kann dabei mit den folgenden Typen umgehen:
// No distinction between Int, Float etc.
let a: number = 5.5;
let b: string = "Test";
// Arrays/lists with dynamic length
let c: number[] = [1, 2, 3];
c.push(4);
// Shapes for arbitrary objects.
// We don't have to define interfaces but can annotate these types inline.
let d: { name: string; age: number } = { name: "Hans", age: 48 };
// Can only contain null
let e: null = null;
// Can only contain undefined
let f: undefined = undefined;
🎓 Wissen: Eine Besonderheit von TypeScript ist, dass Typen immer strukturell verglichen werden und nicht nach Namen (structural vs nominal typing)
let x = { a: 1, b: 2, c: 3 };
function logA(arg: { a: 1 }) {
// ...
}
// Works. Because TypeScript does not care about
// the names or origins of a type, only about
// the structure.
// TypeScript checks if x has all properties
// required by logA and says, that this call
// is fine.
logA(x);
🎓 Wissen: Eines der Designprinzipien von TypeScript ist die nahtlose Interoperabilität von JavaScript Code. Dafür gibt es in TypeScript den any
-Typ. Werte, die mit any
typisiert sind, erlauben zu Compile-Zeit alles!
let x: any = null;
// Will not show a compile error
// but will crash at runtime.
x.some.field.that.does.not.exist(123);
🎓 Wissen: Durch die Inferenz von TypeScript verbreitet sich any, wenn es einmal da ist, wie ein Lauffeuer im System. Du solltest beim Entwickeln stets darauf achten. dass any
nur innerhalb eines Modules/einer Funktion benutzt wird. Die Grenzen zwischen Modulen/Systemen sollten immer richtig typisiert sein.
🎓 Wissen: Seit 2015 unterstützt JavaScript sein eigenes Modul-System (MDN). Mit diesen Modulen können wir unseren Code auf mehrere Dateien aufteilen und klar-definierte Grenzen zwischen diesen Dateien schaffen. TypeScript unterstützt dieses Modulsystem auch vollständig:
// src/lib/library.ts
export const pi = 3;
export function circumference(radius: number) {
return 2 * radius * pi;
}
// src/app.ts
// Paths to own modules must start with .
// Otherwise installed packages from node_modules
// taken
import { circumference } from "./lib/library";
console.log(circumference(10));
🎓 Wissen: Gute IDEs können so konfiguriert werden, dass sie sich selbständig um die Imports kümmern. Ziel muss eigentlich sein, dass wir die Imports nie per Hand anpassen müssen. Wir schreiben im Code einfach circu...
und nutzen das Autocomplete der IDE, um den Import zu verwenden.
🎓 Wissen: Auch wenn wir im Code Typinformationen hinzufügen können, werden diese Annotaionen vom Compiler restlos entfernt. Zur Laufzeit ist es nicht möglich herauszufinden, welcher Typ an einer Variablen definiert war. Das heißt auch, dass JavaScript-Code unseren TypeScript-Code benutzen und falsch aufrufen kann. Wenn wir also eine Bibliothek entwickeln, die auch in JavaScript-Projekten genutzt wird, müssen wir damit rechnen, dass unser Code auch mal "falsch" aufgerufen wird.
🌌 Umfeld: Stell dir vor, du arbeitest an einem System zur Verwaltung eines Online-Shops. In diesem Umfeld wollen wir ab jetzt diverse kleine Übungen durchgehen, um TypeScript besser kennenzulernen
🎯 Ziel: Ersten Code schreiben, verstehen und zum Laufen bringen
- 💪 Lege einen neuen Ordner an:
domain
- 💪 Lege darin einen neuen Ordner an
vat
(für Value Added Tax - Mehrwertsteuer) - 💪 Erstelle eine neue Datei
src/domain/vat/calculations.ts
- 💪 Implementiere die folgenden drei Funktionen:
calculateVAT
- Bekommt den Netto-Preis als Argument und berechnet die Mehrwertsteuer (erstmal mit 19%)calculateTotalPrice
- Bekommt den Netto-Preis als Argument und berechnet den Brutto-PreiscalculatePriceDetails
- Bekommt den Netto-Preis als Argument und gibt ein Objekt mit 3 Feldern zurück:net
,total
undvat
- 💪 Implementiere die folgenden drei Funktionen:
- Importiere die Funktionen in
index.ts
und logge dir mitconsole.log
ein Paar Preise mit Netto, Brutto und Steuer auf die Konsole.
💣 Problem: Du hast jetzt erfolgreich dein erstes TypeScript-Modul entwickelt! Aber bist du dir wirklich sicher, ob alles funktioniert? Eine gute Lösung um das zu prüfen und auch sicherzustellen, dass das so bleibt, sind automatisierte Tests, um die schreiben zu können, brauchen wir noch etwas Infrastruktur.
🎓 Wissen: In der JavaScript-Welt gibt es viele verbreitete Test-Runner und Test-Frameworks. In diesem Workshop möchten wir Jest benutzen. Jest ist sehr einfach einzurichten, sehr umfangreich, dafür aber etwas langsam beim Aufbau der Testumgebung.
🎯 Ziel: Dein erster Test ist geschrieben und kann ausgeführt werden.
- 💪 Führe den Befehl im Terminal aus:
npm i --save-dev jest ts-jest @types/jest
- 💪 Lege im Root deines Projektes eine neue Datei an:
jest.config.js
und befülle sie mit dem folgenden Inhalt:
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
- 💪 Füge ein neues npm-Skript in der
package.json
mit dem Namentest
und dem Befehl"jest"
hinzu. - 💪 Lege eine neue Datei an:
src/domain/vat/calculations.test.ts
und übernimm den folgenden Test-Test:
test("True should be true", () => {
expect(true).toBe(true);
});
- 💪 Starte die Tests mit
npm run test
🎓 Wissen: Du kannst einem npm-Skript auch weitere Command-Line-Arguments mitgeben, indem du die Argumente für das Skript mit --
von den Argumenten für npm abtrennst. Du kannst zum Beispiel: npm run test -- --watch
benutzen, um den Watch-Modus von Jest zu aktivieren.
- 💪 Schreibe Tests (Dokumentation zu Matching-Funktionen) für
calculateVAT
und decke dabei mindestens die folgenden Fälle ab:10
->1.9
12
->2.28
null
-> ⚡ - Soll Laufzeitfehler werfen.
💣 Problem: Das ist ja erstmal schon nicht schlecht. Mich stört aber, dass wir den null-Fall quasi bei jeder Funktion abdecken müssten. Glücklicherweise hat TypeScript dafür eine Lösung!
🎓 Wissen: Aktuell erlaubt uns TypeScript die Typannotationen weg zu lassen und inferiert einfach any
. Zudem erlaubt TypeScript aktuell auch null
an jeden Typen zu übergeben. Das tut TypeScript aber nur, weil es so konfiguriert ist. In Projekten, in denen wir von Anfang an auf TypeScript setzen, können wir uns aber auch für den strikten Modus entscheiden. So kann TypeScript wesentlich mehr Fehler entdecken, schränkt uns aber auch etwas ein, bzw. zwingt uns dazu, explizit zu werden.
🎯 Ziel: Wir möchten von Anfang an im strict mode arbeiten, da TypeScript so wesentlich mehr Fehler finden kann und uns dazu zwingt, unsere Typen genauer zu definieren.
- 💪 Öffne
tsconfig.json
und füge in diecompilerOptions
ein:"strict": true
- 💪 Starte das
tsc-watch
-Skript erneut und behebe alle Compile-Fehler
💣 Problem: Großartig, du musst nun nie wieder auf null prüfen! TypeScript erlaubt es nicht mehr, Funktionen mit null
aufzurufen! Das heißt aber nicht, dass wir null überhaupt nicht mehr nutzen dürfen. Wenn wir es benutzen wollen, müssen wir nur mit angeben, dass Variablen oder Parameter auch null sein dürfen. In diesen Fällen wird TypeScript uns dann zwingen, erst die Null-Variante zu prüfen, bevor wir mit Werten arbeiten.
🎓 Wissen: In TypeScript können wir in eine Variable nicht nur einen Typen legen. Wir können sogenannte Union Types verwenden, um anzugeben, dass in einer Variable der eine oder der andere Typ enthalten ist:
function repeat(x: number | string, times: number) {
if (typeof x === "string") {
// Here, TypeScript knows, that x is of type string
return x.repeat(times);
}
// Here, TypeScript knows that x is of type number
// since the string case already returned.
return x.toFixed(1).repeat(times);
}
repeat(5, 2); // -> 5.05.0
repeat("5", 2); // -> 55
🎓 Wissen: Neben den allgemeinen Typen für string und number, kann TypeScript auch sog. Literal Types verstehen:
// x can contain either the string "ON" or the string "OFF"
let x: "ON" | "OFF" = "ON";
// TypeScript can combine any two or more types with |
let y: number | "T" | null | { x: 5 } = null;
💣 Problem: So können wir schon relativ komplexe Typen zusammenbauen. Aktuell schreiben wir die Typen aber immer entweder direkt an Variablen oder an Funktionsparameter. Wir müssen sie aber immer wieder schreiben 😟
🎓 Wissen: Um unnötige Wiederholung zu vermeiden, können wir sog. Typ Aliase verwenden, um definierte Typen zu referenzieren:
type Switch = 'ON' | 'OFF';
function x(switch: Switch) {
if (switch === 'ON') {
} else {
}
}
// Type aliases can also be used as interfaces!
type PriceDetails = {
vat: number;
net: number;
total: number:
}
🎯 Ziel: Wir wollen unsere Steuer-Funktionen jetzt so anpassen, dass angegeben werden kann, ob der normale Steuersatz oder der reduzierte Steuersatz verwendet werden soll. Wo in anderen Programmiersprachen ein enum benutzt würden müsste, können wir in TypeScript String Literals mit einem Union Type kombinieren, um dieses Problem zu lösen.
- 💪 Definiere einen Union Type
VATType
mit den WertenDEFAULT
(19%) und"REDUCED"
(7%) - 💪 Alle VAT Funktionen sollen als zusätzliches Argument einen VATType bekommen, und diesen in der Berechnung mit einbeziehen.
- 💪 Erweitere deine Tests.
🎓 Wissen: In JavaScript sind Funktionen "first-class-citizens". Das heißt, sie können wie jeder andere Datentyp auch verwendet werden: Du kannst Funktionen in Variablen legen, Funktionen als Argumente übergeben oder auch Funktionen von anderen Funktionen zurück geben lassen. Daher muss auch TypeScript in der Lage sein, den Typ einer Funktion zu definieren:
// Function without arguments that returns nothing
type F1 = () => void;
type F2 = (a: number, b: string) => number;
// Function with arbitrary arguments and arbitrary return type.
type F3 = (...args: any[]) => any;
function computeNumbers(
arr: number[],
compute: (num: number) => number
): number[] {
// TypeScript cannot infer the type of the empty array.
const result: number[] = [];
for (const item of arr) {
result.push(compute(item));
}
return result;
}
// returns 2, 4, 6
computeNumbers([1, 2, 3], (n) => n * 2);
🎯 Ziel: Weitere Domänen-Konzepte sind definiert
- 💪 Definiere einen Union Type für die Produktkategorie mit den Werten:
"ELECTRONICS"
"FOOD"
(reduzierter Steuersatz)"PARTY_SUPPLIES"
- 💪 Definiere einen Typen
Product
mit den folgenden Feldern:- id (Zahl)
- name
- productCategory
- netPrice
- getPriceDetails (berechnet anhand der Kategorie und des Netto-Preises die Steuer-Details) - Rückgabewert soll analog zu
calculatePriceDetails
gebaut werden.
- 💪 Definiere eine Funktion
createProduct
, die als Argumente Name, Preis & Kategorie erhält und ein Produkt-Objekt erzeugt. Für die ID soll eine zufällige Zahl zwischen 100.000 und 999.999 generiert werden. - 💪 Schreibe Tests für
createProduct
und die resultierenden Produkte
🎓 Wissen: In TypeScript sind die Typ-Ebene und die Wert-Ebene erstmal strikt voneinander getrennt. Es kann in beiden Welten den gleichen Identifier geben, ohne, dass es zu Konflikten kommt, da bei jeder Referenz eindeutig ist, ob diese in der Typ-Ebene oder der Wert-Ebene gilt.
// Type and value can share same name.
type Identifier = { a: number };
let Identifier = { a: 1 };
// After ":", a type is expected, after "a" a value
let x: Identifier = Identifier;
🎓 Wissen: Einen Wert von der Typ-Ebene in die Wert-Ebene zu verschieben ist nicht möglich. Andersherum allerdings schon! Wir können aus bestimmten Werten den inferierten Typ extrahieren und auf Typ-Ebene heben:
const person = { name: "Peter", age: 58 };
// Using typeof on type level lifts a value
// into the type level.
type Person = typeof person;
// Person is now { name: string, age: number }
🎓 Wissen: TypeScript inferiert bei string und number literals immer den "geweiteten" Typ string oder number, also in unserem Beispiel von oben nicht den literal Type "Peter"
sondern string
. Dieses Verhalten können wir mit Hilfe von const-Assertions noch weiter einschränken:
let x = { switch: "ON" as const };
// X = { switch: "ON"; }
type X = typeof x;
let y = ["A", "B", "C"] as const;
// Y = readonly ["A", "B", "C"]
type Y = typeof y;
🎓 Wissen: TypeScript erlaubt uns nicht nur, Typen aus Laufzeitwerten heraus zu generieren, wir können auch Typen aus anderen Typen extrahieren, kombinieren und so viel Schreib- und Wartungsarbeit sparen:
type BaseEntity = {
id: string;
};
// Always gets the type of the id field
// When we change BaseEntity.id at a later point in time
// EntityId will adapt automatically.
type EntityId = BaseEntity["id"];
// Whenever we need a new value for our switches
// We add it here and the Type of a SwitchValue will
// automatically adapt.
const switchValues = ["ON", "OFF", "INDETERMINATE"] as const;
// SwitchValues = readonly ["ON", "OFF", "INDETERMINATE"]
type SwitchValues = typeof switchValues;
// [number] extracts the union type of all indexes
// of an array
// SwitchValue = "ON" | "OFF" | "INDETERMINATE"
type SwitchValue = SwitchValues[number];
🎯 Ziel: Wir möchten jetzt automatisiert einige Mock-Produkte erzeugen. Damit das geht, brauchen wir zur Laufzeit eine Liste aller möglichen Werte für den VAT-Type und die Produktkategorie. Um Werte nicht doppelt pflegen zu müssen, wollen wir die Typen aus Laufzeitwerten ableiten.
- 💪 Nutze const-assertions und den typeof Operator, um die Union-Types für
VATType
undProductCategory
aus Laufzeit-Werten zu extrahieren. - 💪 Schreibe eine Funktion
generateMockProduct
, welche ein zufälliges Produkt (zufällige Kategorie, zufälliger Preis zwischen 1 und 15€ auf 2 Dezimalstellen gerundet) und automatisch generierten Name (Produkt - Zufallszahl) erzeugt. - 💪 Nutze
generateMockProduct
, um 10 zufällige Produkte zu erzeugen. - 💪 Definiere eine neue Funktion
filterProducts
. Ziel dieser Funktion ist es, aus der Liste der Produkte alle Produkte zu extrahieren, die die gleichen Felder wie Argument 2 haben. Die Funktion soll dafür 2 Argumente bekommen:- eine Liste von Produkten
- ein Objekt, auf dem OPTIONAL alle Datenfelder (name, id, productCategory & netPrice) aber NICHT die Funktion angegeben werden kann.
- 💪 Schreibe Tests für
filterProducts
, du kannst dich dabei an den folgenden Beispielen orientieren:
let products: Product[] = [
/* ... */
];
// returns all food products
let filtered1 = filterProducts(products, { productCategory: "FOOD" });
// returns all products that have the id 123456 AND have a price of 10
let filtered2 = filterProducts(products, { id: 123456, price: 10 });
// Should throw a TypeScript error, since getPriceDetails is not a valid
// field to filter by (only data fields can be used)
let filtered3 = filterProducts(products, { getPriceDetails: () => 1 });
💣 Problem: Wir haben jetzt eine schön dynamische Filterfunktion gebaut, wenn wir bei Produkten allerdings ein neues Feld hinzufügen, müssen wir selbst daran denken, die Filterfunktion anzupassen. Damit müssen wir wieder zwei Stellen im Code manuell synchron halten. Mit TypeScript kann das allerdings in den meisten Fällen vermieden werden!
🎓 Wissen: TypeScripts Fähigkeit, Typen aus anderen Typen zu erzeugen ist einer der USPs der Programmiersprache. (Ja, TypeScript Typen sind Turing-Vollständig) Eine ganze Sektion der Dokumentation widmet sich diesem Thema: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html Die Basics sind in folgendem Beispiel dargestellt:
type T1 = { a: number; b: string };
// T2 = 'a' | 'b'
type T2 = keyof T1;
// number | string, same as T1[keyof T1]
type T3 = T1[T2];
// Creates a new type T4, where every key
// of T1 is optional. The values have the same types
type T4 = {
[Key in keyof T1]?: T1[Key];
};
💣 Problem: Damit können wir Typen sehr flexibel Transformieren. Diese Transformationen sind so aber nicht wiederverwendbar. Was wir brauchen, sind "Funktionen" die auf Typen arbeiten. Und genau das sind Generics:
type ValueOf<TObject> = TObject[keyof TObject];
type T1 = { a: number; b: string };
// string | number
type ValuesOfT1 = ValueOf<T1>;
type PartialObject<TObject> = {
[Key in keyof TObject]?: TObject[Key];
};
// PartialT1 = {
// a?: number | undefined;
// b?: string | undefined;
// }
type PartialT1 = PartialObject<T1>;
🎓 Wissen: Viele dieser Typ-Transformationen sind bereits in TypeScript eingebaut und global verfügbar (wie z.B. Partial
, eine eingebaute Implementierung des PartialObject
Beispiels). Eine Liste dieser eingebauten Typen findet sich in der Dokumentation.
- 💪 Füge auf dem Produkt ein weiteres Feld
description
hinzu und behebe alle Compile-Fehler. Hinweis: Nutze das tsc-Script was wir definiert haben. - 💪 Nachdem du die Typfehler beseitigt hast, überlege, an welchen Stellen das neue Feld jetzt noch hinzugefügt werden muss...
- 💪 Anstatt den
Product
Typ selbst zu definieren, wollen wir diesen aus dem Rückgabewert voncreateProduct
extrahieren. Nutze dazutypeof
+ weitere Hilfstypen - 💪 Anstatt das Partielle Produkt von
filterProducts
manuell anzugeben, soll dieses aus dem ursprünglichenProduct
Typ generiert werden. Achte daruaf, dass das FeldgetPriceDetails
nicht enthalten ist. - 💯 Zusatzaufgabe für Experten: Schreibe den Typ für das partielle Produkt so, dass automatisch nur die Felder angegeben werden können, in denen KEINE Funktionen liegt. Wenn also auf
Product
eine weitere Funktion z.B.serialize
gepflegt wird, soll danach nicht gefiltert werden dürfen (ohne Anpassungen anfilterProducts
) und wenn ein weiteres Datenfeld dazukommt, soll es automatisch mit gefiltert werden können.
💣 Problem: Aktuell verlassen wir uns darauf, dass die Typen in unserem System immer zur Implementierung passen. Es ist uns gerade nicht möglich, Funktionen auch mal "Falsch" aufzurufen, um z.B. Fehlerbehandlung zu testen. Dafür brauchen wir Lösungen:
function sum(a: number, b: number): number {
return a + b;
}
test("sum should throw an error when called with anything but numbers", () => {
// TypeScript will complain here and will always show an error :(
expect(() => sum(1, "two")).toThrow();
// Solution 1: any
let arg: any = "two";
expect(() => sum(1, arg)).toThrow();
expect(() => sum(1, "two" as any)).toThrow();
// Solution 2: @ts-ignore
// @ts-ignore we want to test this behavior
expect(() => sum(1, "two")).toThrow();
// Solution 3: @ts-expect-error. Will throw a type error,
// When the following line DOES NOT throw an error.
// @ts-expect-error
expect(() => sum(1, "two")).toThrow();
});
- 💪 Schreibe einen Test, der prüft, ob
calculateVAT
einen Fehler wirft, wenn sie mit einem unbekannten VAT-Type aufgerufen werden. - 💪 Füge bei
VATType
einen neuen Wert hinzu:TEMPORARY_COVID_VAT
💣 Problem: Immerhin können wir jetzt einen Test für unbekannte VAT-Typen schreiben. Beim Hinzufügen eines neuen Wertes müssen wir aber immer noch selbst wissen, ob wir diesen neuen Wert überall hinzugefügt haben. Unter Umständen hast du die letzte Aufgabe schon so interpretiert, dass natürlich die Berechnungsfunktionen auch mit diesem neuen Wert umgehen können müssen. Hätte jemand anderes das auch gewusst? Hätte jemand das gewusst, der gerade neu ins Projekt gekommen ist? Irgendwie müssten wir eine Möglichkeit finden, den Compiler dazu zu nutzen, alle Stellen zu finden, wo wir ALLE Optionen abprüfen wollen...
🎓 Wissen: Wir können uns hier ein Feature von TypeScript zu nutzen machen, was wir ganz am Anfang schon mal abgedeckt hatten: Die Control-Flow-Analyse von TypeScript
function repeat(x: number | string, times: number) {
if (typeof x === "string") {
// Here, TypeScript knows, that x is of type string
return x.repeat(times);
}
if (typeof x === "number") {
// Here, TypeScript knows that x is of type number
// since the string case already returned.
return x.toFixed(1).repeat(times);
}
// TypeScript KNOWS that we can never reach this spot,
// since x can only be of type string or number.
// TypeScript annotates this variable with the never type.
// This line will only NOT throw a type error, when x is also of type never.
let result: never = x;
// To help non-TS users of our code, we can additionally throw an error.
throw new Error(`x has unexpected type ${typeof x}`);
}
💣 Problem: Diese zwei Zeilen sind schon sehr nützlich, aber du musst dich immer an diese Kombination erinnern... Wir sollten das in eine eigene Funktion schreiben:
// This function never returns a value,
// since it always throws an error.
// Only use this in places that will never
// get executed under normal circumstances.
function assertNever(x: never): never {
throw new Error(`x has unexpected type ${typeof x}`);
}
function repeat(x: number | string, times: number) {
if (typeof x === "string") {
return x.repeat(times);
}
if (typeof x === "number") {
return x.toFixed(1).repeat(times);
}
assertNever(x);
}
- 💪 Passe deine Berechnungsfunktionen so an, dass sie alle Optionen des
VATType
abdecken und einen Type-Fehler zeigen, wenn irgendwann ein weitere Typ hinzugefügt wird. - 💪 Erzeuge eine Lookup-Map von ProductCategory-Werten auf
VATTypes
, sodass wir an einer zentralen Stelle gepflegt haben, welche Steuer bei welcher Produktkategorie verwendet wird. Schreibe hier zuerst ein Typ-Alias für ein Objekt, welches alle Werte vonProductCategory
als Schlüssel hat und jedem dieser SchlüsselVATType
zuordnet. Danach können wir die Laufzeitvariable mit den richtigen Werten definieren. So sind wir wieder abgesichert, dass wir für jede Produktkategorie definiert haben, welcher Steuersatz relevant ist.
🎉 Gratuliere, du hast es geschafft! Du bist mit dem Kerninhalt des Workshops durch und hast einen ersten Einblick in die Arbeit mit TypeScript erhalten. Wie geht es jetzt weiter? Am besten mit der Arbeit an einem richtigen Projekt. Installiere zum Beispiel Fastify, um dein Projekt um eine REST-Schnittstelle zu erweitern. Die Validierung von Inputs könntest du dabei mit Zod so implementieren, dass auch direkt der richtige Typ für die Inputs herauspurzelt. Für eine erste Persitenz von Daten, kannst du dir die FileSystem-API von Node.js anschauen und die Daten erstmal in eine JSON-Datei speichern.