Ich hab ja schonmal einen Guide fĂŒr Dokumentationen verlinkt. Hier ist auch noch eine eher praktischere ErgĂ€nzung: What nobody tells developers about documentation
Posts mit Tag "software-engineering"
Eine tolle Idee, wie man Farben beim Syntax-Highlighting anders verwenden kann: Syntax highlighting is a waste of an information channel
Interesing Read: Die REST API-Guidelines bei Zalando.
Vor ein paar Tagen habe ich Hurl kennengelernt. Das ist ein CLI-HTTP-Client, wie cURL. Der Unterschied ist: Hurl holt sich die Request-Parameter aus einer Datei.
Warum ist das so viel anders als cURL? Man kann mehrere HTTP-Anfragen in einer Datei abbilden. Diese Anfragen können Daten aus den Ergebnissen der vorherigen Anfrage verwenden. Aus dem README:
# Get home:
GET https://example.net
HTTP/1.1 200
[Captures]
csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)"
# Do login!
POST https://example.net/login?user=toto&password=1234
X-CSRF-TOKEN: {% raw %}{{csrf_token}}{% endraw %}
HTTP/1.1 302
Wie Ihr seht, kann man damit auch XPath-Querys auf die Antworten absetzen. NatĂŒrlich geht auch JSONPath. Das kann man mit einem [Asserts] kombinieren und sich somit HTTP-Tests bauen:
POST https://api.example.net/tests
{
"id": "456",
"evaluate": true
}
HTTP/1.1 200
[Asserts]
jsonpath "$.status" == "RUNNING" # Check the status code
jsonpath "$.tests" count == 25 # Check the number of items
Intern verwendet das Tool natĂŒrlich cURL; man muss das Rad ja auch nicht neu erfinden.
Neulich hatte ich den bekannten âParse, donât validateâ-Post verlinkt.
Als Follow-Up-Empfehlung gibt es hier zwei Posts:
Letzteres dreht sich vor allem um Haskells newtype. Vereinfacht gesagt ist das ein strikter Typalias. Also als TypeScript-Ăquivalent:
type Grade = number;
Mit einem wichtigen Unterschied: Jede number ist automatisch eine valide Grade. Das liegt daran, dass das Typsystem von TS (an den meisten Stellen) strukturell und nicht nominell ist.
Diese newtypes sind eine strikte Variante davon, nĂ€mlich, dass man die Typen entweder explizit casten muss oder sie durch die Typinferenz gesichert werden. In TS gibt es newtype-Konstruktionen, Librarys und etliche Blogposts dazu. Manche nennen es auch âBranded Typesâ. NatĂŒrlich gibt es auch ein seit 2014 offenes Proposal dazu.
Die Aussage des Blogposts oben: Das kann Exhaustiveness-Checking kaputt machen. Wir stellen uns diese Funktion vor:
type Grade = Branded<number, "Grade">; // "newtype" fĂŒr Grade
function isGrade(value: number): value is Grade {
return 1 <= value && value <= 6;
}
function getGradeDescription(value: Grade): string {
switch (value) {
case 1: return "sehr gut";
case 2: return "gut";
case 3: return "befriedigend";
case 4: return "ausreichend";
case 5: return "mangelhaft";
case 6: return "ungenĂŒgend";
default: throw new Error("Impossible");
}
}
let a = 4;
// a ist "number"
if (isGrade(a)) {
// a ist "Grade"
console.log(getGradeDescription(a));
}
Das ist doch schon ganz gut. Aber was ist jetzt das Problem?
Das Problem wird klar, wenn wir ein Refactoring machen und von dem 6-Noten-System auf z. B. ein 15-Punkte-System migrieren:
type Grade = Branded<number, "Grade">; // "newtype" fĂŒr Grade
function isGrade(value: number): value is Grade {
return 0 <= value && value <= 15;
}
// ...
let a = 4;
// a ist "number"
if (isGrade(a)) {
// a ist "Grade"
console.log(getGradeDescription(a));
}
Jetzt könnte es passieren, dass der Entwickler nicht mitbekommt, dass getGradeDescription auch angepasst werden muss â es gibt ja auch keinen Compilerfehler. Stattdessen erhalten wir einen Runtime-Fehler. Dabei dachten wir eigentlich, wir seien auf der sicheren Seite, denn wir haben immer den Grade-Type zugesichert.
Oben schrieb ich, dass Exhaustiveness-Checking kaputt gemacht wĂŒrde. Rollen wir unseren Code also nochmal zurĂŒck vor das Refactoring und schauen, was wir hĂ€tten besser machen können.
Exhaustiveness-Checking ist, wenn der Compiler prĂŒfen kann, ob alle FĂ€lle abgetestet wurden. Wenn wir den default-Case beim getGradeDescription weglassen:
function getGradeDescription(value: Grade): string {
switch (value) {
case 1: return "sehr gut";
case 2: return "gut";
case 3: return "befriedigend";
case 4: return "ausreichend";
case 5: return "mangelhaft";
case 6: return "ungenĂŒgend";
}
}
âŠerhalten wir einen Fehler:
Function lacks ending return statement and return type does not include âundefinedâ.
Der kommt daher, dass wir nicht alle FĂ€lle abgedeckt haben und die Funktion nicht immer einen Wert zurĂŒckgibt, denn Grade ist ja letztenendes fĂŒr den Compiler nur eine number, welche alle möglichen Werte annehmen kann. Wir wissen jedoch, dass dies nicht so ist! Der Type-Checker weiĂ das jedoch nicht. Wie können wir den Type-Checker zu unseren Gunsten verwenden?
Eine Antwort: Mit Literaltypen und Union-Types. Wir können Grade stattdessen so definieren:
type Grade = 1 | 2 | 3 | 4 | 5 | 6;
(Achtung: Das ist immernoch kein newtype, nur ein Typalias fĂŒr dieses Union)
Dieser Typalias reicht schon aus, um den Compiler bei dem switch mit dem fehlenden default-Case zu befriedigen. Wenn wir jetzt unser Refactoring erneut durchfĂŒhren, mĂŒssen wir Grade abĂ€ndern:
type Grade = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
Jetzt bekommt wir sofort einen Fehler in der getGradeDescription-Funktion: Das Exhaustiveness-Checking sagt uns, dass wir nicht alle FĂ€lle des Grade-Typen abgedeckt haben und der Entwickler weiĂ sofort, dass er diese Funktion anpassen muss, da sie nicht vergessen werden kann.
Zwei letzte Anmerkungen dazu:
- Den
default-Case wĂŒrde ich in der TypeScript-Welt trotzdem nicht weglassen. Es ist immer gut, sich einassertNeverzu definieren und es imdefault-Case fĂŒr den Wert, auf dem geswitched wird, zu verwenden. Dadurch wird der Fehler eindeutiger und sollte es zur Laufzeit doch irgendwie dazu kommen, kann im Fehelrfall zumindest eine Exception an der richtigen Stelle geworfen werden (TypeScript macht keine Runtime-Checks; eine inkorrekte Assertion wĂŒrde dafĂŒr reichen). - Der Typ ist jetzt schon ziemlich lang â ein Union mit 16 EintrĂ€gen. NatĂŒrlich sollte man auf Fallbasis abwĂ€gen, ob es sich lohnt.
- newtypes / branded Types sind trotzdem cool und können generell helfen, typsicherer zu sein. Es kann sich aber lohnen, seine Typen noch genauer zu spezifizieren.
Aus der Reihe âBlogposts, die man kennen sollteâ: Parse, donât validate.
Wenn Ihr eine Anwendung baut, macht ungĂŒltige ZustĂ€nde in Eurer TypdomĂ€ne nicht-reprĂ€sentierbar. Ihr mĂŒsst dann anschlieĂend nichts mehr validieren, sondern nur noch parsen. Letzteres macht ggf. sogar ein Framework fĂŒr Euch. Und Ihr zwingt Euch dazu, FehlerfĂ€lle nicht zu ĂŒbersehen.
Ein einfaches Beispiel in TypeScript: Szenario: Ein Server kann zwei Antworten geben:
{ "ok": true, "data": "Bitteschön" }
{ "ok": false, "message": "Ich bin ein Kaffeepott" }
Was man nicht machen sollte, wĂ€re folgendes DTO als Modellierung fĂŒr die Antwort zu nehmen:
interface Response {
ok: boolean;
message?: string;
data?: string;
}
const r = await fetch("...").then(r => r.json()) as Response;
Warum nicht?
- Weil man andauernd prĂŒfen muss, ob
messagevorhanden ist. - Weil es bei diesem DTO gĂŒltig ist, dass das Objekt weder
messagenochdatahat. Dieser ungĂŒltige Zustand wĂ€re in dieser Modellierung möglich! - Weil man vergessen könnte, auf
okzu ĂŒberprĂŒfen. - Kann bei Refactorings kaputt gehen.
Was könnte man stattdessen machen? TypeScript hat (wie andere Sprachen auch) discriminated/tagged Unions. Rust-Menschen kennen das als ihr Enum, nur dass das in TS auf JS-Objekten funktioniert. Dabei fungiert ein- oder mehrere gemeinsame Propertys der Typen als Discriminator (also âUnterscheiderâ).
Wir definieren genau die zwei Möglichkeiten, die uns der Server geben kann und sagen âdas oder dasâ:
interface SuccessResponse {
ok: true;
data: string;
}
interface ErrorResponse {
ok: false;
message: string;
}
type Response = SuccessResponse | ErrorResponse;
const r = await fetch("...").then(r => r.json()) as Response;
ok ist hier der Discriminator, der zwischen den beiden Typen unterscheidet.
AuffÀllig ist:
- Weder
messagenochdatasind jetzt optional. - Man wird vom Typsystem gezwungen, auf
okzu prĂŒfen, bevor man.dataverwendet. Man kann es nicht vergessen. - Eine Funktion, die nur mit einer erfolgreichen Serverantwort etwas anfangen kann, kann dies in ihrer Parametersignatur sagen. Man spart sich das entpacken der Antwort sowie sonstige Checks innerhalb der Funktion.
- Wenn Refactorings etwas daran Àndern, merkt man das.
- Es ist nicht möglich,
ok: trueundmesage: "test"zu haben - ungĂŒltige ZustĂ€nde können hier nicht reprĂ€sentiert werden.
Eine ĂberprĂŒfung, ob die Antwort jetzt erfolgreich war oder nicht, muss man natĂŒrlich so frĂŒh wie möglich machen, dann spart man sich das ĂberprĂŒfen an spĂ€teren Stellen.
Das oben gezeigte Pattern lĂ€sst sich gut verwenden, um State-Machines typsicher zu implementieren. Noch ein paar Pointer fĂŒr andere Sprachen:
- std::variant fĂŒr >= C++17
- mypy kann tagged Unions. Ob das mypy-spezifisch ist, weiĂ ich leider nicht.
- Interesting find: Seit Python 3.10 kann man
A | BfĂŒr Unions nehmen (stattUnion[A, B]); das macht es weniger verbose in der Nutzung
- Interesting find: Seit Python 3.10 kann man
- Java 17 macht sowas mit Sealed Classes und spÀter noch etwas angenehmer mit Records.
- Wikipedia zu Tagged Unions / Sum Types, mit Code-Beispielen
Das ist nur eine Methode, ungĂŒltige ZustĂ€nde im Typsystem festzuhalten. Aber eine, die (meiner Meinung nach) zu wenig verwendet wird.