Typannotationen verwende ich in TypeScript wie Gewürze beim Kochen: gezielt und wohldosiert. Ich schreibe sie üblicherweise (nicht immer) in meine Funktionssignaturen und an Klassenfelder, meist aber nicht an Variablen (außer, wenn ich es eben doch mache). Das bedeutet, dass in meinem TypeScript-Code vergleichsweise wenig TypeScript vorkommt. Wenn ich Sie schreibe, dann stelle ich Typannotationen immer in den Dienst des durchgehenden, konsequenten Abbildens von Garantien. Insbesondere versuche ich bei Funktionen, innere und äußere Garantien konsistent zu halten. Was meine ich damit?
Garantien und Funktionssignaturen
Betrachten wir als Beispiel einmal die folgende Funktion:
import { SelectorMap } from "somewhere"; // Map<string, Element[]> const getElements = (selectors: string[]): SelectorMap => { /* egal */ };
Die Funktion steht allein in einem Modul und fungiert als Abstraktion über document.querySelector()
. Sie wählt Elemente anhand von CSS-Selektoren aus und ordnet Sie in einer Map ihrem jeweiligen Selektor zu:
const elements = getElements = (".foo", "#bar"); // elements === Map(2) { ".foo" => [ HTMLDivElement ], "#bar" => [ HTMLParagraphElement ] }
In ihrer TypeScript-Typsignatur beschreibt die Funktion eine Datentransformation. Ich nenne das die äußere Garantie; die Beschreibung dessen, was die Funktion für ihre Nutzer leistet. In diesem Fall besteht diese Leistung darin, dass sie aus einer Liste von Selektor-Strings … ja, was eigentlich genau macht?
Der Typ SelectorMap
wird aus einem anderen Modul importiert und stellt offenbar gerade eine Map<string, Element[]>
dar. Ich würde aber behaupten, dass SelectorMap
und Map<string, Element[]>
effektiv zwei unterschiedliche Typen sind, auch wenn sie kompatibel sind. Der Unterschied ist, dass SelectorMap
ein von irgendwoher importierter Typ ist, der ggf. nicht unserer Kontrolle als Schreiber von getElements
unterliegt. Das verändert die Garantie, welche die Funktionssignatur formuliert, auf ziemlich drastische Weise:
// ich liefere immer Map<string, Element[], SelectorMap ist mir egal const getElements = (selectors: string[]): Map<string, Element[]> => { ... }; // ich verspreche, immer eine SelectorMap zu liefern, egal wie diese genau aussieht const getElements = (selectors: string[]): SelectorMap => { ... };
Spürbar wird der Unterschied, wenn tatsächlich eines Tages durch ein Refactoring die Typen SelectorMap
und Map<string, Element[]>
inkompatibel werden:
- Beim Rückgabetyp
Map<string, Element[]>
manifestiert sich das Problem an den Stellen, an denengetElements()
aufgerufen wird und probiert wird, das Resultat alsSelectorMap
zu verwenden - Beim Rückgabetyp
SelectorMap
manifestiert sich das Problem angetElements()
selbst, da es plötzlich seinem eigenen Anspruch nicht mehr gerecht wird.
Der Unterschied ist subtil, aber im Ernstfall durchaus spürbar. Auf diesen feinen Unterschied zu achten bringt weitreichende Konsequenzen mit sich, sobald es an die konkrete Implementierung von Funktionen und vor allem verschachtelte Funktionen geht.
Äußere vs. innere Garantien
Implementieren wir doch mal getElements()
mithilfe von Array.prototype.map
:
import { SelectorMap } from "somewhere"; // Map<string, Element[]>
const getElements = (selectors: string[]): SelectorMap => {
const keyValuePairs = selectors.map( (selector) => {
return [ selector, [ ...document.querySelectorAll(selector) ] ];
});
return new Map(keyValuePairs); // Nicht zulässig!
};
Dieser Code funktioniert so nicht, denn TypeScript lässt uns nicht keyValuePairs
in new Map()
stecken. Die Typinferenz ermittelt für keyValuePairs
den Typ Array<Array<string | Element[]>>
statt des von uns beabsichtigten (und vom Map-Constructor benötigten) Array<[string, Element[] ]>
. Für dieses Problem gibt es (jenseits von any
) zwei Lösungen. Zum einen könnten wir TypeScript mit as const
beibringen, dass der Rückgabetyp von (x: A) => [ x, x ]
als Tuple [ A, A ]
statt als Array A[]
zu verstehen ist. Leider macht das auch den Tuple-Inhalt readonly
, was bei es für unser Beispiel unbrauchbar macht – denn in der SelectorMap
sollen schließlich Arrays und keine ReadonlyArrays landen.
Eine Rückgabe-Typannotation [ string, Element[] ]
für den Map-Callback würde da schon besser funktionieren, ebenso wie die Typannotation Array<[ string, Element[] ]>
an der Variable keyValuePairs
:
import { SelectorMap } from "somewhere"; // Map<string, Element[]> const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs = selectors.map( (selector): [ string, Element[] ] => { return [ selector, [ ...document.querySelectorAll(selector) ] ]; }); return new Map(keyValuePairs); // klappt }; const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs: Array<[ string, Element[] ]> = selectors.map( (selector) => { return [ selector, [ ...document.querySelectorAll(selector) ] ]; }); return new Map(keyValuePairs); // klappt };
Das Problem an dieser Stelle ist, welche Garantien diese Typannotationen an der inneren Funktion genau geben. Die äußere Funktion getElements()
verspricht, aus einem Array von Selektor-Strings eine SelectorMap
zu machen (was immer das auch im einzelnen genau sein mag), der innere Map-Callback hingegen verspricht, aus einem String ein Tuple [ string, Element[] ]
zu machen. Was haben SelectorMap
und [ string, Element[] ]
gemeinsam? Direkt eigentlich gar nichts! Wir können davon ausgehen, dass die Keys und Values von SelectorMap
mal kompatibel zu den Typen string
und Element[]
waren (es vielleicht sogar auch immer noch sind), aber ist das wirklich, was wir ausdrücken wollen?
Was der Map-Callback eigentlichen garantieren sollte, ist, dass er aus einem Teil-Input (einem string
von vielen) einen Teil-Output (ein Name-Wert-Paar für SelectorMap
) macht. Stattdessen garantiert er, aus einem string
ein Name-Wert-Paar [ string, Element[] ]
zu machen. Die jeweiligen Outputs mögen kompatibel sein, sind es aber definitionsgemäß nur so lange, bis sich die Typen ändern. Die äußere Garantie der Haupt-Funktion und die innere Garantie des Map-Callbacks sind zeitweise kompatibel, aber sie bauen nicht aufeinander auf.
Konsistente Garantien durch Hilfs-Typen
Was wir an dieser Stelle eigentlich ausdrücken möchten, ist durch einen Hilfs-Typ wie Entry<Map>
zu bewerkstelligen. Dieses schöne Stück schwarze TypeScript-Magie …
type Entry <M extends Map <any, any>> = M extends Map <infer Key, infer Value> ? [ Key, Value ] : [ any, any ];
… extrahiert aus einem Map-Typ einen Tuple-Typ (den Entry) bestehend aus dem Typ des Keys und dem Typ des Values. Der Typ Entry<M>
erwartet als Eingabe-Typargument eine Map (Bedingung M extends Map<any, any>
) und versucht mittels Typinferenz (infer
-Keyword) die Typen der Keys und Values der Map zu ermitteln, jeweils mit any
als Fallback. Mit diesem Helferlein ist es uns jetzt möglich, die in der Typsignatur von getElements()
gegebene äußeren Garantien in die Implementierung hereinzutragen:
const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs = selectors.map( (selector): Entry<SelectorMap> => { return [ selector, [ ...document.querySelectorAll(selector) ] ]; }); return new Map(keyValuePairs); };
Das Garantiegeflecht ist jetzt frei von potenziellen Widersprüchen: getElements()
macht aus dem Gesamt-Input den Gesamt-Output SelectorMap
und die Teiloperationen machen aus Teilen des Gesamt-Inputs Teile des Gesamt-Outputs (Key-Value-Tuples für SelectorMap
, wie immer die auch aussehen mögen).
Der Härtest im Refactoring
Bei diesen Überlegungen handelt es sich mitnichten um zweckfreie Abstraktions-Astronautik. Je nachdem wie wir das Garantiegeflecht ausdrücken, manifestieren sich Fehler an sehr unterschiedlichen Stellen. Simulieren wir doch mal ein Refactoring und machen aus der SelectorMap
, ehemals eine Map<string, Element[]>
, eine Map<string, HTMLElement[]>
. Wichtig ist: HTMLElement
ist ein Subtyp von Element
, d.h. in der neuen Map sind z.B. SVG-Elemente nicht mehr willkommen. Was macht diese Änderung von SelectorMap
mit unseren verschieden annotierten Implementierungen?
import { SelectorMap } from "somewhere"; // neuerdings Map<string, HTMLElement[]> // mit [ string, Element[] ] für den Callback const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs = selectors.map( (selector): [ string, Element[] ] => { return [ selector, [ ...document.querySelectorAll(selector) ] ]; }); return new Map(keyValuePairs); /* Fehler hier */ }; // mit Array<[ string, Element[] ]> für die Variable const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs: Array<[ string, Element[] ]> = selectors.map( (selector) => { return [ selector, [ ...document.querySelectorAll(selector) ] ]; }); return new Map(keyValuePairs); /* Fehler hier */ }; // mit Entry<SelectorMap> const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs = selectors.map( (selector): Entry<SelectorMap> => { return [ selector, [ ...document.querySelectorAll(selector) ] /* Fehler hier */ ]; }); return new Map(keyValuePairs); };
In allen drei Fällen warnt uns TypeScript vor Fehlern, was schon mal sehr positiv ist. Allerdings handelt es sich um zwei sehr unterschiedliche Fehler! In den ersten beiden Fällen läuft das Problem darauf hinaus, dass unser Garantiegeflecht plötzlich Widersprüche enthält und der Fehler tritt an der Stelle zutage, wo die unterschiedlichen Garantien von äußerer Funktion uns innerem Callback nicht mehr zusammenpassen. Beim letzten Beispiel verweist die Fehlermeldung hingegen auf die Stelle, wo unser in sich konsistentes Garantiegeflecht mit inkompatiblen anderen Programmteilen kollidiert bzw. wo unsere TypeScript-Garantien (Values sind HTMLElement
) vom Runtime-JavaScript (document.querySelectorAll()
liefert Element
) nicht mehr eingehalten werden. Und eigentlich ist letzteres ja das, was wir von TypeScript wollen! Es soll uns vor unerwartetem Runtime-JavaScript warnen und uns nicht dafür tadeln, dass wir uns in unseren eigenen Typannotationen verlaufen haben!
Es handelt sich bei den Fehlern in den ersten beiden Beispielen nicht um andere Manifestationen des gleichen Problems, sondern um hausgemachte Extra-Fehler, die aus den Widersprüchen in den Typsignaturen erwachsen. Sobald wir anfangen, die Fehler zu reparieren, d.h. Element
durch HTMLElement
zu ersetzen, landen wir als Folgefehler bei genau dem gleichen Problem, bei dem wir mit dem dritten Beispiel schon von Anfang an waren:
import { SelectorMap } from "somewhere"; // neuerdings Map<string, HTMLElement[]> // mit [ string, HTMLElement[] ] für den Callback const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs = selectors.map( (selector): [ string, HTMLElement[] ] => { return [ selector, [ ...document.querySelectorAll(selector) ] /* Fehler jetzt hier */ ]; }); return new Map(keyValuePairs); }; // mit Array<[ string, HTMLElement[] ]> für die Variable const getElements = (selectors: string[]): SelectorMap => { const keyValuePairs: Array<[ string, HTMLElement[] ]> = selectors.map( (selector) => { return [ selector, [ ...document.querySelectorAll(selector) ] /* Fehler jetzt hier */ ]; }); return new Map(keyValuePairs); };
Nachdem wir unsere eigenen Widersprüche aufgelöst haben, können wir uns endlich an die Beseitigung des eigentlichen Problems kümmern, das nun mal an document.querySelectorAll()
hängt. Aber besser wäre es natürlich, wenn wir gar keine eigenen Widersprüche in unseren Code einbauen würden und immer direkt zum Kern des Problems kommen würden. Dafür müssten wir als TypeScript-Nutzer drei Dinge tun:
- Funktionen und Klassen konsequent mit Typsignaturen ausstatten, damit diese als Eckpfeiler unseres Garantiegeflechts fungieren können
- Transparente Typannotationen ohne Widersprüche schreiben! Das bedeutet vor allem, dass wir bei verschachtelten Funktionen o.Ä. die Typen für die Signaturen innerer Funktionen mit Utilities wie
Entry<T>
aus den Typen für die Signaturen der äußeren Funktionen konstruieren - Weniger Typannotationen schreiben, vor allem an Variablen nur sehr dosiert
Oder anders formuliert: wir müssen über Typannotationen nachdenken und sie nicht einfach überall hinkleistern, nur weil wir können.
Weniger Typannotationen schreiben!
Nichts baut schneller Widersprüche in TypeScript ein, als das Folgende:
let x: Thing = createStuff();
Solange die Typsignatur createStuff()
eine ordentliche Typsignatur hat, ist die Annotation Thing
an x
überflüssig – das findet die Typinferenz von TypeScript auch alleine raus. Schlimmer noch: wenn createStuff()
umgebaut wird, und plötzlich kein Thing
mehr, sondern ein PartialThing
(etwas, das prinzipiell im Programm Sinn ergibt, aber technisch gesehen kein Subtyp von Thing
ist) produziert, gibt es hier einen Fehler. Wieso eigentlich? Es ist ja nicht so, dass die JavaScript-Variable x
kein PartialThing
aufnehmen könnte. Sollte x
später von etwas verwendet werden, dass ein Thing
erwartet, wird dann eben an dieser Stelle scheppern:
let x = createStuff(); // liefert PartialThing
consumeThing(x);
// Fehler!
// An der Stelle wo es ein Problem wird!
// WENN es kein PartialThing akzeptiert!
Der Schlüssel zu widerspruchsfreien Typannotationen sind zuallererst weniger Typannotationen – nicht, weil Typannotationen schlecht wären, sondern weil sie zum Formulieren von Widersprüchen einladen. Zum Glück sind sie aber an vielen Stellen dank Typinferenz nicht nötig und eine gute IDE zeigt auch an, welchen Typ die Typinferenz für eine Variable ermittelt hat. Typannotationen gehören also in der Regel nur an Funktionen und Klassen und nur ausnahmsweise (da wo es sinnvoll und/oder nötig ist) an frei laufende Variablen.