Fortgeschrittenes TypeScript mit einem echten Beispiel (Teil 2)

Veröffentlicht: 2020-11-20

Es ist an der Zeit, mit unserem TypeScript-Tutorial fortzufahren (und es hoffentlich abzuschließen). Wenn Sie die vorherigen Posts verpasst haben, die wir über TypeScript geschrieben haben, hier sind sie: unsere erste Einführung in TypeScript und der erste Teil dieses Tutorials, in dem ich das JavaScript-Beispiel erkläre, mit dem wir arbeiten, und die Schritte, die wir unternommen haben, um es teilweise zu verbessern .

Heute werden wir unser Beispiel beenden, indem wir alles vervollständigen, was noch fehlt. Insbesondere werden wir zuerst sehen, wie Typen erstellt werden, die Teilversionen anderer vorhandener Typen sind. Anschließend sehen wir uns an, wie man die Aktionen eines Redux-Stores mit Type Unions korrekt typisiert und welche Vorteile Type Unions bieten. Und schließlich zeige ich Ihnen, wie Sie eine polymorphe Funktion erstellen, deren Rückgabetyp von ihren Argumenten abhängt.

Ein kurzer Rückblick auf das, was wir bisher getan haben…

Im ersten Teil des Tutorials haben wir (einen Teil) eines Redux-Speichers verwendet, den wir Nelio Content als Arbeitsbeispiel entnommen haben. Alles begann als einfacher JavaScript-Code, der durch das Hinzufügen konkreter Typen verbessert werden musste, um ihn robuster und verständlicher zu machen. So haben wir beispielsweise folgende Typen definiert:

 type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; };

Dies hat uns geholfen, auf einen Blick zu verstehen, mit welcher Art von Informationen unser Geschäft arbeitet. In diesem speziellen Fall können wir beispielsweise sehen, dass der Status unserer Anwendung zwei Dinge speichert: eine Liste von posts (die wir über ihre PostId indiziert haben) und eine Struktur namens days , die bei einem bestimmten Tag eine Liste von zurückgibt Post-Identifikatoren. Wir können auch die Attribute (und ihre spezifischen Typen) sehen, die wir in einem Post Objekt finden.

Nachdem diese Typen definiert waren, haben wir alle Funktionen unseres Beispiels bearbeitet, um sie zu verwenden. Diese einfache Aufgabe transformierte die undurchsichtigen Funktionssignaturen von JavaScript:

 // Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }

zu selbsterklärenden TypeScript-Funktionssignaturen:

 // Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... }

Die getPostsInDay Funktion ist ein sehr gutes Beispiel dafür, wie stark TypeScript die Qualität Ihres Codes verbessert. Wenn Sie sich das JavaScript-Gegenstück ansehen, wissen Sie wirklich nicht, was diese Funktion zurückgeben wird. Sicher, sein Name könnte auf den Ergebnistyp hinweisen (ist es vielleicht eine Liste von Beiträgen?), aber Sie müssen sich den Quellcode der Funktion (und wahrscheinlich auch die Aktionen und Reduzierungen) ansehen, um sicher zu sein (es ist eigentlich eine Liste von Post-IDs). Man kann diese Situation verbessern, indem man Dinge besser benennt (zum Beispiel getIdsOfPostsInDay ), aber es gibt nichts Besseres als konkrete Typen, um jeden Zweifel auszuräumen: PostId[] .

Jetzt, da Sie mit dem aktuellen Stand der Dinge auf dem Laufenden sind, ist es an der Zeit, weiterzumachen und alles zu reparieren, was wir letzte Woche übersprungen haben. Insbesondere wissen wir, dass wir die Attribute attributes der Funktion updatePost und die Typen definieren müssen, die unsere Aktionen haben werden (beachten Sie, dass das Attribut action in reducer jetzt vom Typ any ist).

So geben Sie ein Objekt ein, dessen Attribute eine Teilmenge der eines anderen Objekts sind

Lassen Sie uns aufwärmen, indem wir mit etwas Einfachem beginnen. Die updatePost Funktion generiert eine Aktion, die unsere Absicht signalisiert, bestimmte Attribute einer bestimmten Beitrags-ID zu aktualisieren. So sieht es aus:

 function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }

und so wird die Aktion vom Reducer verwendet, um den Beitrag in unserem Shop zu aktualisieren:

 function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }

Wie Sie sehen können, durchsucht der Reducer den Beitrag im Geschäft und aktualisiert, falls vorhanden, seine Attribute, indem er sie mit denen überschreibt, die in der Aktion enthalten sind.

Aber was genau sind die attributes einer Aktion? Nun, sie sind eindeutig etwas, das einem Post ähnelt, da sie die Attribute überschreiben sollen, die wir möglicherweise in einem Post finden:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };

aber wenn wir versuchen, dies zu verwenden, werden wir sehen, dass es nicht funktioniert:

 const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, };

weil wir nicht wollen, dass attributes selbst ein Post sind; Wir möchten, dass es sich um eine Teilmenge von Post Attributen handelt (dh wir möchten nur die Attribute eines Post Objekts angeben, die wir überschreiben werden).

Um dieses Problem zu lösen, verwenden Sie einfach den Partial :

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };

Und das ist es! Oder ist es?

Attribute explizit filtern

Das vorherige Snippet ist immer noch fehlerhaft, da einige Laufzeitfehler auftreten können, die der Compiler von TypeScript nicht überprüft. Hier ist der Grund: Die Aktion, die eine Post-Aktualisierung signalisiert, besteht aus zwei Argumenten, einer Post-ID und dem Satz von Attributen, die wir aktualisieren möchten. Sobald wir die Aktion bereit haben, ist der Reducer dafür verantwortlich, den vorhandenen Beitrag mit den neuen Werten zu überschreiben:

 const post = { ...state.posts[ action.postId ], ...action.attributes, };

Und genau das ist der fehlerhafte Teil in unserem Code; Es ist möglich, dass das postId -Attribut der Aktion eine Pots-ID x hat und das id -Attribut in attributes eine andere Post-ID y hat:

 const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, };

Dies ist offensichtlich eine gültige Aktion, und daher löst TypeScript keine Fehler aus, aber wir wissen, dass dies nicht der Fall sein sollte. Das id -Attribut in attributes (falls vorhanden) und das postId Attribut sollten den gleichen Wert haben, sonst haben wir eine inkohärente Aktion. Unser Aktionstyp ist ungenau, weil wir damit eine Situation definieren können, die unmöglich sein sollte … also wie können wir das beheben? Ganz einfach: Ändern Sie diesen Typ einfach so, dass dieses Szenario, das unmöglich sein sollte, tatsächlich unmöglich wird.

Die erste Lösung, an die ich dachte, ist die folgende: Entfernen Sie das postId Attribut aus der Aktion und fügen Sie die ID im attributes -Attribut hinzu:

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; }

Aktualisieren Sie dann Ihren Reducer so, dass er action.attributes.id anstelle von action.postId verwendet, um den vorhandenen Beitrag zu finden und zu überschreiben.

Leider ist diese Lösung nicht ideal, da attributes ein „Teilpost“ ist, erinnerst du dich? Das bedeutet, dass das id Attribut theoretisch im attributes Objekt enthalten sein kann oder nicht. Sicher, wir wissen, dass es da sein wird, weil wir diejenigen sind, die die Action erzeugen … aber unsere Typen sind immer noch ungenau. Wenn in Zukunft jemand die Funktion updatePost ändert und nicht sicherstellt, dass attributes die postId , wäre die resultierende Aktion laut TypeScript gültig, aber unser Code würde nicht funktionieren:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

Wenn wir also möchten, dass TypeScript uns schützt, müssen wir bei der Angabe von Typen so genau wie möglich sein und sicherstellen, dass sie unmögliche Zustände unmöglich machen. In Anbetracht all dessen stehen uns nur zwei Optionen zur Verfügung:

  1. Wenn wir ein postId Attribut in Aktion haben (wie wir es am Anfang getan haben), dann darf das attributes -Objekt kein id -Attribut enthalten.
  2. Wenn die Aktion andererseits kein postId Attribut hat, müssen attributes ein id -Attribut enthalten.

Die erste Lösung kann einfach mit einem anderen Hilfstyp, Omit , angegeben werden, mit dem wir einen neuen Typ erstellen können, indem wir Attribute aus einem vorhandenen Typ entfernen:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };

was wie erwartet funktioniert:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, };

Für die zweite Option müssen wir das Attribut id explizit über dem von uns definierten Typ Partial<Post> hinzufügen:

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };

was uns wiederum das erwartete Ergebnis liefert:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

Unionstypen

Im vorherigen Abschnitt haben wir bereits gesehen, wie man eine der beiden Aktionen eingibt, die unser Geschäft hat. Machen wir dasselbe mit der zweiten Aktion. Zu wissen, dass receiveNewPost so aussieht:

 function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }

sein Typ kann wie folgt definiert werden:

 type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };

Einfach richtig?

Schauen wir uns nun unseren Reducer an: Er nimmt einen state und eine action (deren Typ wir noch nicht kennen) und erzeugt einen neuen State :

 function reducer( state: State, action: any ): State { ... }

Unser Geschäft hat zwei verschiedene Arten von Aktionen: UpdatePostAction und ReceiveNewPostAction . Also, was ist die Art des action ? Das eine oder andere, oder? Wenn eine Variable mehr als einen Typ A , B , C usw. akzeptieren kann, ist ihr Typ eine Vereinigung von Typen. Das heißt, sein Typ kann entweder A oder B oder C sein und so weiter. Ein Union-Typ ist ein Typ, dessen Werte von jedem der in dieser Union angegebenen Typen sein können.

So kann unser Action -Typ als Union-Typ definiert werden:

 type Action = UpdatePostAction | ReceiveNewPostAction;

Das vorherige Snippet besagt lediglich, dass eine Action entweder eine Instanz des Typs UpdatePostAction oder eine Instanz des Typs ReceiveNewPostAction .

Wenn wir nun Action in unserem Reducer verwenden:

 function reducer( state: State, action: Action ): State { ... }

Wir können sehen, wie diese neue Version unseres gut typisierten Codes reibungslos funktioniert.

Wie Union-Typen Standardfälle eliminieren

„Warte mal“, könntest du sagen, „der vorherige Link funktioniert nicht reibungslos, der Compiler löst einen Fehler aus!“ Tatsächlich enthält unser Reducer laut TypeScript unerreichbaren Code:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }

Warte was? Lassen Sie mich erklären, was hier vor sich geht …

Der von uns erstellte Action -Vereinigungstyp ist eigentlich ein Diskriminierungs-Vereinigungstyp. Ein diskriminierter Union-Typ ist ein Union-Typ, bei dem alle seine Typen ein gemeinsames Attribut teilen, dessen Wert verwendet werden kann, um einen Typ vom anderen zu unterscheiden.

In unserem Fall haben die beiden Action -Typen ein type Attribut, dessen Werte RECEIVE_NEW_POST für ReceiveNewPostAction und UPDATE_POST für UpdatePostAction . Da wir wissen, dass eine Action notwendigerweise eine Instanz entweder der einen oder der anderen Aktion ist, decken die beiden Zweige unseres switch alle Möglichkeiten ab: entweder action.type ist RECEIVE_NEW_POST oder es ist UPDATE_POST . Daher ist die endgültige return überflüssig und nicht erreichbar.

Angenommen, wir entfernen diese return , um diesen Fehler zu beheben. Haben wir etwas gewonnen, außer unnötigen Code zu entfernen? Die Antwort ist ja. Wenn wir jetzt einen neuen Aktionstyp in unseren Code einfügen:

 type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... };

plötzlich deckt die switch Anweisung in unserem Reducer nicht mehr alle möglichen Szenarien ab:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit }

Dies bedeutet, dass der Reducer implizit einen undefined Wert zurückgeben kann, wenn wir ihn mit einer Aktion vom Typ NEW_FEATURE , und das ist etwas, das nicht mit der Signatur der Funktion übereinstimmt. Aufgrund dieser Diskrepanz beschwert sich TypeScript und teilt uns mit, dass uns ein neuer Zweig fehlt, um mit diesem neuen Aktionstyp umzugehen.

Polymorphe Funktionen mit variablen Rückgabetypen

Wenn Sie es bis hierher geschafft haben, herzlichen Glückwunsch: Sie haben alles gelernt, was Sie tun müssen, um den Quellcode Ihrer JavaScript-Anwendungen mit TypeScript zu verbessern. Und als Belohnung werde ich Ihnen ein „Problem“ mitteilen, auf das ich vor ein paar Tagen gestoßen bin, und seine Lösung. Warum? Denn TypeScript ist eine komplexe und faszinierende Welt und ich möchte Ihnen zeigen, inwieweit das zutrifft.

Zu Beginn dieses ganzen Abenteuers haben wir gesehen, dass einer der Selektoren, die wir haben, getPostsInDay ist und dass sein Rückgabetyp eine Liste von Post-IDs ist:

 function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }

Auch wenn der Name vermuten lässt, dass es eine Liste von Beiträgen zurückgeben könnte. Warum habe ich einen so irreführenden Namen verwendet, fragen Sie sich? Nun, stellen Sie sich folgendes Szenario vor: Angenommen, Sie möchten, dass diese Funktion abhängig vom Wert eines ihrer Argumente entweder eine Liste mit Post-IDs oder eine Liste mit tatsächlichen Posts zurückgeben kann. Etwas wie das:

 const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );

Können wir das in TypeScript machen? Natürlich machen wir das! Warum sollte ich das sonst ansprechen? Wir müssen lediglich eine polymorphe Funktion definieren, deren Ergebnis von den Eingabeparametern abhängt.

Die Idee ist also, dass wir zwei verschiedene Versionen derselben Funktion wollen. Man sollte eine Liste von PostId , wenn eines der Attribute die string id ist. Der andere sollte eine Liste von Post zurückgeben, wenn dasselbe Attribut die string all ist.

Lassen Sie uns beide erstellen:

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }

Einfach richtig? FALSCH! Das funktioniert nicht. Laut TypeScript haben wir eine „duplizierte Funktionsimplementierung“.

Okay, dann probieren wir mal was anderes. Lassen Sie uns die beiden vorherigen Definitionen zu einer einzigen Funktion zusammenführen:

 function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }

Verhält sich das so, wie wir es wollen? Ich fürchte, das tut es nicht …

Folgendes sagt uns diese Funktionssignatur: „ getPostsInDay ist eine Funktion, die zwei Argumente akzeptiert, einen state und einen mode deren Werte entweder id oder all sein können; sein Rückgabetyp ist entweder eine Liste von PostId oder eine Liste von Post .“ Mit anderen Worten, die vorherige Funktionsdefinition gibt nirgendwo an, dass eine Beziehung zwischen dem an das mode übergebenen Wert und dem Rückgabetyp der Funktion besteht. Und so Code wie folgt:

 const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

gültig ist und sich nicht so verhält, wie wir es wollen.

OK, letzter Versuch. Was wäre, wenn wir unsere anfängliche Intuition, bei der wir konkrete Funktionssignaturen beschreiben, mit einer einzigen, gültigen Implementierung mischen?

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); }

Das vorherige Snippet hat eine gültige Funktionsimplementierung, die funktioniert, aber zwei zusätzliche Funktionssignaturen definiert, die konkrete Werte im mode mit dem Rückgabetyp der Funktion binden.

Bei diesem Ansatz ist dieser Code gültig:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

und dieser nicht:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );

Schlussfolgerungen

In dieser Beitragsserie haben wir gesehen, was TypeScript ist und wie wir es in unseren Projekten anwenden können. Typen helfen uns, den Code besser zu dokumentieren, indem sie einen semantischen Kontext bereitstellen. Darüber hinaus fügen Typen auch eine zusätzliche Sicherheitsebene hinzu, da der TypeScript-Compiler dafür sorgt, dass unser Code korrekt zusammenpasst, genau wie Legos.

An dieser Stelle haben Sie bereits alle notwendigen Werkzeuge, um die Qualität Ihrer Arbeit auf die nächste Stufe zu heben. Viel Glück in diesem neuen Abenteuer!

Vorgestelltes Bild von Mike Kenneally auf Unsplash.