Erweitertes TypeScript

Freitauchen - Tauchen ohne Tauchen. Der Taucher spürt das Gesetz von Archimedes: Er verdrängt eine bestimmte Menge Wasser, die ihn zurückdrückt. Daher sind die ersten Meter am härtesten, aber dann hilft die Druckkraft der Wassersäule über Ihnen, sich tiefer zu bewegen. Dieser Prozess erinnert an das Lernen und Eintauchen in TypeScript-Systeme - er wird beim Tauchen etwas einfacher. Aber wir dürfen nicht vergessen, rechtzeitig aufzutauchen.


Foto von einem Ozean ein Atemzug .

Mikhail Bashurov (Saitonakamura) - Senior Frontend Engineer bei WiseBits, ein TypeScript-Fan und Amateur-Freitaucher. Die Analogien von TypeScript lernen und tief tauchen sind nicht zufällig. Michael wird Ihnen sagen, was diskriminierte Gewerkschaften sind, wie man Typinferenz verwendet, warum Sie nominelle Kompatibilität und Branding benötigen. Halten Sie den Atem an und tauchen Sie.

Präsentation und Links zu GitHub mit Beispielcode hier .

Art von Arbeit


Die Arten von Summen klingen algebraisch - versuchen wir herauszufinden, was es ist. Sie werden in ReasonML als Variante, in Haskell als Union und in F # und TypeScript als Union bezeichnet.

Wikipedia gibt die folgende Definition: "Die Art des Betrags ist die Summe der Arten von Werken."
- Danke Kapitän!

Die Definition ist absolut korrekt, aber nutzlos, also lasst uns verstehen. Gehen wir von privat aus und beginnen mit der Art der Arbeit.

Angenommen, wir haben einen Typ Task. Es hat zwei Felder: idund wer hat es erstellt.

type Task = { id: number, whoCreated: number }

Diese Art von Arbeit ist Kreuzung oder Kreuzung . Dies bedeutet, dass wir den gleichen Code wie jedes Feld einzeln schreiben können.

type Task = { id: number } & { whoCreated: number }

In der Algebra der Sätze wird dies als logische Multiplikation bezeichnet: Dies ist die Operation „UND“ oder „UND“. Die Art des Produkts ist das logische Produkt dieser beiden Elemente.
Ein Objekt mit einer Reihe von Feldern kann durch ein logisches Produkt ausgedrückt werden.
Die Art der Arbeit ist nicht auf Felder beschränkt. Stellen Sie sich vor, wir lieben Prototype und haben festgestellt, dass wir vermisst werden leftPad.

type RichString = string & { leftPad: (toLength: number) => string }

Fügen Sie es hinzu String.prototype. Um in Typen auszudrücken, verwenden wir einen String und eine Funktion, die die erforderlichen Werte annimmt. Die Methode und die Zeichenfolge sind der Schnittpunkt.

Einen Verband


Die Vereinigung - Vereinigung - ist beispielsweise nützlich, um den Typ einer Variablen darzustellen, die die Breite eines Elements in CSS festlegt: eine Zeichenfolge mit 10 Pixeln oder einen absoluten Wert. Die CSS-Spezifikation ist tatsächlich viel komplizierter, aber der Einfachheit halber lassen wir es so.

type Width = string | number

Ein Beispiel ist komplizierter. Angenommen, wir haben eine Aufgabe. Es kann in drei Zuständen vorliegen: gerade erstellt, in die Arbeit aufgenommen und abgeschlossen.

type InitialTask = { id: number, whoCreated: number }
type InWorkTask = { id: number, whoCreated: number }
type FinishTask = { id: number, whoCreated: number, finishDate: Date }

type Task = InitialTask | InWorkTask | FinishedTask

Im ersten und zweiten Status hat die Aufgabe idund wer hat sie erstellt, und im dritten Status wird das Abschlussdatum angezeigt. Hier erscheint die Vereinigung - entweder dies oder das.

Hinweis . Stattdessen können typeSie verwenden interface. Aber es gibt Nuancen. Das Ergebnis von Vereinigung und Schnittmenge ist immer ein Typ. Sie können Schnittmenge durch ausdrücken extends, Vereinigung durch interfacejedoch nicht. Es ist typenur kürzer.

interfacehilft nur beim Zusammenführen von Deklarationen. Bibliothekstypen müssen immer durch ausgedrückt werden interface, da dies anderen Entwicklern ermöglicht, sie mit ihren Feldern zu erweitern. So typisiert zum Beispiel jQuery-Plugins.

Typkompatibilität


Es gibt jedoch ein Problem - in TypeScript Kompatibilität mit strukturellen Typen . Dies bedeutet, dass wenn der erste und der zweite Typ denselben Satz von Feldern haben, dies zwei vom gleichen Typ sind. Zum Beispiel haben wir 10 Typen mit unterschiedlichen Namen, aber mit identischer Struktur (mit denselben Feldern). Wenn wir einen der 10 Typen für eine Funktion bereitstellen, die einen von ihnen akzeptiert, hat TypeScript nichts dagegen.

In Java oder C # dagegen ein nominales Kompatibilitätssystem . Wir werden die gleichen 10 Klassen mit identischen Feldern und einer Funktion schreiben, die eine davon übernimmt. Eine Funktion akzeptiert keine anderen Klassen, wenn sie keine direkten Nachkommen des Typs sind, für den die Funktion bestimmt ist.

Das Problem der strukturellen Kompatibilität wird behoben, indem dem Feld der Status hinzugefügt wird: enumoderstring. Summentypen sind jedoch eine Möglichkeit, Code semantischer auszudrücken als in der Datenbank gespeichert.

Als Beispiel werden wir uns an ReasonML wenden. So sieht dieser Typ dort aus.

type Task =
| Initial({ id: int, whoCreated: int })
| InWork({ id: int, whoCreated: int })
| Finished({
        id: int,
        whoCreated: int,
        finshDate: Date
    })

Die gleichen Felder, außer dass intstattdessen number. Wenn Sie genau hinschauen, stellen wir fest Initial, InWorkund Finished. Sie befinden sich nicht links im Typnamen, sondern rechts in der Definition. Dies ist nicht nur eine Zeile im Titel, sondern Teil eines separaten Typs, sodass wir die erste von der zweiten unterscheiden können.

Life Hack . Erstellen Sie für generische Typen (wie Ihre Domänenentitäten) eine Datei global.d.tsund fügen Sie alle Typen hinzu. Sie werden automatisch in der gesamten TypeScript-Codebasis angezeigt, ohne dass ein expliziter Import erforderlich ist. Dies ist praktisch für die Migration, da Sie sich keine Gedanken darüber machen müssen, wo Typen platziert werden sollen.

Lassen Sie uns am Beispiel des primitiven Redux-Codes sehen, wie das geht.

export const taskReducer = (state, action) = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, erro: action.payload }
    }
    
    return state
}

Schreiben Sie den Code in TypeScript neu. Beginnen wir mit dem Umbenennen der Datei - von .jsbis .ts. Aktivieren Sie alle Statusoptionen und die Option noImplicitAny. Diese Option findet Funktionen, in denen der Parametertyp nicht angegeben ist, und ist in der Migrationsphase nützlich.

Typisierbaren State: add Feld isFetching, Task(das kann nicht sein) und Error.

type Task = { title: string }

declare module "*.jpg" {
    const url: string
    export default url
}

declare module "*.png" {
    const url: string
    export default url
}

Hinweis. Lifehack guckte in Basarat Ali Syeds TypeScript Deep Dive . Es ist etwas veraltet, aber dennoch nützlich für diejenigen, die in TypeScript eintauchen möchten. Lesen Sie im Blog von Marius Schulz über den aktuellen Stand von TypeScript . Er schreibt über neue Funktionen und Tricks. Studieren Sie auch Änderungsprotokolle . Es gibt nicht nur Informationen zu Updates, sondern auch deren Verwendung.

Verbleibende Aktionen. Wir werden jeden von ihnen eingeben und deklarieren.

type FetchAction = {
    type: "TASK_FETCH"
}

type SuccessAction = {
    type: "TASK_SUCCESS",
    payload: Task

type FailAction = {
    type: "TASK_FAIL",
    payload: Error
}

type Actions = FetchAction | SuccessAction | FailAction

Das Feld typefür alle Typen ist unterschiedlich. Es ist nicht nur eine Zeichenfolge, sondern eine bestimmte Zeichenfolge - ein Zeichenfolgenliteral . Dies ist der Diskriminator - das Etikett, anhand dessen wir alle Fälle unterscheiden. Wir haben eine diskriminierte Gewerkschaft und können zum Beispiel verstehen, was in der Nutzlast steckt.

Aktualisieren Sie den ursprünglichen Redux-Code:

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
    }
    
    return state
}

Hier besteht Gefahr, wenn wir schreiben string...
type FetchAction = {
    type: string
}
... dann wird alles kaputt gehen, weil es kein Diskriminator mehr ist.

In dieser Aktion kann type eine beliebige Zeichenfolge sein und kein bestimmter Diskriminator. Danach kann TypeScript eine Aktion nicht mehr von einer anderen unterscheiden und Fehler finden. Daher sollte es genau ein String-Literal geben. Darüber hinaus können wir dieses Design hinzufügen : type ActionType = Actions["type"].

Drei unserer Optionen werden in den Eingabeaufforderungen angezeigt.



Wenn Sie schreiben ...
type FetchAction = {
    type: string
}
... dann sind die Eingabeaufforderungen einfach string, da alle anderen Zeilen nicht mehr wichtig sind.



Wir haben alle getippt und die Art des Betrags erhalten.

Umfassende Prüfung


Stellen Sie sich eine hypothetische Situation vor, in der dem Code keine Fehlerbehandlung hinzugefügt wird.

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        //case "TASK_FAIL":
            //return { isFetching: false, error: action.payload }
    }
    
    return state
}

Hier hilft uns eine umfassende Überprüfung - eine weitere nützliche Eigenschaft wie die Summe . Dies ist eine Gelegenheit zu überprüfen, ob wir alle möglichen Fälle bearbeitet haben.

Fügen Sie nach der switch-Anweisung eine Variable hinzu : const exhaustiveCheck: never = action.

Nie ist ein interessanter Typ:

  • Wenn es das Ergebnis einer Funktionsrückgabe ist, wird die Funktion nie korrekt beendet (z. B. wird immer ein Fehler ausgegeben oder endlos ausgeführt).
  • Wenn es sich um einen Typ handelt, kann dieser Typ nicht ohne zusätzlichen Aufwand erstellt werden.

Jetzt zeigt der Compiler den Fehler "Typ 'FailAction' kann nicht dem Typ 'nie' zugewiesen werden". Wir haben drei mögliche Arten von Aktionen, die wir nicht verarbeitet haben "TASK_FAIL"- das ist FailAction. Aber niemals ist es nicht „zuweisbar“.

Fügen wir die Verarbeitung hinzu "TASK_FAIL", und es werden keine Fehler mehr auftreten. Verarbeitet "TASK_FETCH"- zurückgegeben, verarbeitet "TASK_SUCCESS"- zurückgegeben, verarbeitet "TASK_FAIL". Was könnte die Aktion sein, wenn wir alle drei Funktionen verarbeitet haben? Nichts - never.

Wenn Sie eine weitere Aktion hinzufügen, teilt Ihnen der Compiler mit, welche nicht verarbeitet werden. Dies ist hilfreich, wenn Sie auf alle Aktionen reagieren möchten und wenn nur selektiv, dann nein.

Erster Tipp

Anstrengen.
Zuerst wird es schwierig sein zu tauchen: Lesen Sie über die Art der Summe, dann über Produkte, algebraische Datentypen und so weiter in der Kette. Muss sich anstrengen.

Wenn wir tiefer tauchen, steigt der Druck der Wassersäule über uns. In einer bestimmten Tiefe sind die Auftriebskraft und der Wasserdruck über uns ausgeglichen. Dies ist eine Zone des "neutralen Auftriebs", in der wir uns in einem Zustand der Schwerelosigkeit befinden. In diesem Zustand können wir uns anderen Typsystemen oder Sprachen zuwenden.

Nominal Type System


Im alten Rom hatten die Einwohner drei Bestandteile des Namens: Nachname, Name und "Spitzname". Der Name selbst ist "Nomen". Daraus ergibt sich das nominelle Typensystem - "nominal". Es ist manchmal nützlich.

Zum Beispiel haben wir einen solchen Funktionscode.

export const githubUrl = process.env.GITHUB_URL as string
export const nodeEnv = process.env.NODE_ENV as string

export const fetchStarredRepos = (
    nodeEnv: string,
    githubUrl: string
): Promise<GithubRepo[ ]> => {
    if (nodeEnv = "production") {
        // log call
    }

    // prettier-ignore
    return fetch('$githubUrl}/users/saitonakamura/starred')
        .then(r => r.json());
}

Die Konfiguration erfolgt über die API : GITHUB_URL=https://api.github.com. Die githubUrlAPI- Methode zieht die Repositorys heraus, auf die wir ein Sternchen setzen. Und wenn nodeEnv = "production", dann protokolliert es diesen Aufruf, zum Beispiel für Metriken.

Für die Funktion wollen wir entwickeln (UI).

import React, { useState } from "react"

export const StarredRepos = () => {
    const [starredRepos, setStarredRepos] = useState<GithubRepo[ ] | null>(null)

    if (!starredRepos) return <div>Loading…</div>

    return (
        <ul>
            {starredRepos.map(repo => (
                <li key={repo.name}>repo.name}</li>
            ))}
        </ul>
    )
}

type GithubRepo = {
    name: string
}

Die Funktion weiß bereits, wie Daten angezeigt werden, und wenn es keine gibt, dann einen Lader. Es bleibt noch ein API-Aufruf hinzuzufügen und die Daten zu füllen.

useEffect(() => {
    fetchStarredRepos(githubUrl, nodeEnv).then(data => setStarredRepos(data))
}, [ ])

Aber wenn wir diesen Code ausführen, wird alles fallen. Im Entwicklerfenster stellen wir fest, dass es fetchauf die Adresse zugreift '/users/saitonakamura/starred'- githubUrl ist irgendwo verschwunden. Es stellt sich heraus, dass alles wegen des seltsamen Designs an nodeEnverster Stelle steht. Natürlich kann es verlockend sein, alles zu ändern, aber die Funktion kann an anderen Stellen in der Codebasis verwendet werden.

Was aber, wenn der Compiler dies im Voraus anfordert? Dann müssen Sie nicht den gesamten Startzyklus durchlaufen, einen Fehler erkennen und nach den Gründen suchen.

Branding


TypeScript hat einen Hack dafür - Markentypen. Erstellen Brand, B(string), Tund zwei Typen. Wir werden den gleichen Typ für erstellen NodeEnv.

type Brand<T, B extends string> = T & { readonly _brand: B }

type GithubUrl = Brand<string, "githubUrl">
export const githubUrl = process.env.GITHUB_URL as GithubUrl

type NodeEnv = Brand<string, "nodeEnv">
export const nodeEnv = process.env.NODE_ENV as NodeEnv

Aber wir bekommen einen Fehler.



Jetzt ist githubUrles nodeEnvunmöglich, sich gegenseitig zuzuweisen, da es sich um nominelle Typen handelt. Unauffällig, aber nominal. Jetzt können wir sie hier nicht mehr austauschen - wir ändern sie in einem anderen Teil des Codes.

useEffect(() => {
    fetchStarresRepos(nodeEnv, githubUrl).then(data => setStarredRepos(data))
}, [ ])

Jetzt ist alles in Ordnung - sie haben Markenprimitive . Branding ist nützlich, wenn mehrere Argumente (Zeichenfolgen, Zahlen) auftreten. Sie haben eine bestimmte Semantik (x-, y-Koordinaten) und sollten nicht verwechselt werden. Es ist praktisch, wenn der Compiler Ihnen mitteilt, dass sie verwirrt sind.

Es gibt jedoch zwei Probleme. Erstens hat TypeScript keine nativen Nominaltypen. Es besteht jedoch die Hoffnung, dass das Problem behoben wird. Eine Diskussion dieses Problems wird im Sprachrepository fortgesetzt .

Das zweite Problem ist "as", es gibt keine Garantie dafür, dass die GITHUB_URLVerbindung nicht unterbrochen wird.

Sowie mit NODE_ENV. Höchstwahrscheinlich wollen wir nicht nur einen String, sondern "production"oder "development".

type NodeEnv = Brand<"production" | "development" | "nodeEnv">
export const nodeEnv = process.env.NODE_ENV as NodeEnv

All dies muss überprüft werden. Ich verweise Sie auf intelligente Designer und den Bericht von Sergey Cherepanov, " Entwerfen einer Domain mit TypeScript in einem funktionalen Stil ".

Zweiter und dritter Tipp

Sei vorsichtig.
Halten Sie manchmal an und schauen Sie sich um: in anderen Sprachen, Frameworks, Typsystemen. Lerne neue Prinzipien und lerne Lektionen.

Wenn wir den Punkt des "neutralen Auftriebs" passieren, drückt das Wasser stärker und zieht uns nach unten.
Entspannen.
Tauchen Sie tiefer und lassen Sie TypeScript seinen Job machen.

Was TypeScript kann?


TypeScript kann Typen drucken .

export const createFetch = () => ({
    type: "TASK_FETCH"
})

export const createSuccess = (task: Task) => ({
    type: "TASK_SUCCESS"
    payload: Task
})

export const createFail = (error: Error) => ({
    type: "TASK_FAIL"
    payload: error
})

type FetchAction = {
    type: "TASK_FETCH",

type SuccessAction = {
    type: "TASK_SUCCESS",
    payload: Task

type FailAction = {
    type: "TASK_FAIL",
    payload: Error
}

Dafür gibt es TypeScript ReturnType- es gibt den Rückgabewert von der Funktion ab:
type FetchAction = ReturnType<typeof createFetch>
Darin übergeben wir die Art der Funktion. Wir können einfach keine Funktion schreiben: Um einen Typ aus einer Funktion oder Variablen zu nehmen, müssen wir schreiben typeof.

Wir sehen in den Tipps type: string.



Das ist schlecht - der Diskriminator wird brechen, weil es ein Objektliteral gibt.

export const createFetch = () => ({
    type: "TASK_FETCH"
})

Wenn wir ein Objekt in JavaScript erstellen, ist es standardmäßig veränderbar. Dies bedeutet, dass wir in einem Objekt mit einem Feld und einer Zeichenfolge die Zeichenfolge später in eine andere ändern können. Daher erweitert TypeScript eine bestimmte Zeichenfolge auf eine beliebige Zeichenfolge für veränderbare Objekte.

Wir müssen TypeScript irgendwie helfen. Dafür gibt es als const.

export const createFetch = ( ) => ({
    type: "TASK_FETCH" as const
})

Add - String verschwindet sofort in den Eingabeaufforderungen. Wir können dies nicht nur über die Linie schreiben, sondern im Allgemeinen das gesamte Literal.

export const createFetch = ( ) => ({
    type: "TASK_FETCH"
} as const)

Dann wird der Typ (und alle Felder) schreibgeschützt.

type FetchAction = {
    readonly type: "TASK_FETCH";
}

Dies ist nützlich, da es unwahrscheinlich ist, dass Sie die Veränderlichkeit Ihrer Aktion ändern. Deshalb fügen wir überall als const hinzu.

export const createFetch = () => ({
    type: "TASK_FETCH"
} as const)

export const createSuccess = (task: Task) => ({
    type: "TASK_SUCCESS"
    payload: Task
} as const)

export const createFail = (error: Error) => ({
    type: "TASK_FAIL"
    payload: error
} as const)

type Actions =
    | ReturnType<typeof createFetch>
    | ReturnType<typeof createSuccess>
    | ReturnType<typeof createFail>

type State =
    | { isFetching: true }
    | { isFetching: false; task: Task }
    | { isFetching: false; error: Error }

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

  const _exhaustiveCheck: never = action

  return state
}

Alle Aktionen, die Code eingeben, wurden reduziert und als const hinzugefügt. TypeScript verstand alles andere.

TypeScript kann State ausgeben . Es wird durch die Vereinigung im obigen Code mit drei möglichen Zuständen von isFetching dargestellt: true, false oder Task.

Verwenden Sie den Typ State = ReturnType. TypeScript-Eingabeaufforderungen zeigen an, dass eine zirkuläre Abhängigkeit besteht.



Verkürzen.

type State = ReturnType<typeof taskReducer>

export const taskReducer = (state, action: Actions) => {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

const _exhaustiveCheck: never = action

return state
}

Statehörte auf zu fluchen, aber jetzt ist er es any, weil wir eine zyklische Abhängigkeit haben. Wir geben das Argument ein.

type State = ReturnType<typeof taskReducer>

export const taskReducer = (state: { isFetching: true }, action: Actions) => {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

const _exhaustiveCheck: never = action

return state
}

Der Abschluss ist fertig.



Die Schlussfolgerung ist , ähnlich dem , was wir hatten ursprünglich: true, false, Task. Es gibt hier Müllfelder Error, aber mit dem Typ undefined- das Feld scheint da zu sein, aber es scheint nicht.

Vierter Tipp

Überarbeiten Sie nicht.
Wenn Sie sich entspannen und zu tief tauchen, ist möglicherweise nicht genügend Sauerstoff vorhanden, um zurückzukehren.

Das Training auch: Wenn Sie zu sehr in die Technologie vertieft sind und sich dafür entscheiden, sie überall anzuwenden, werden Sie höchstwahrscheinlich auf Fehler stoßen, deren Gründe Sie nicht kennen. Dies führt zur Ablehnung und möchte keine statischen Typen mehr verwenden. Versuchen Sie, Ihre Stärke zu bewerten.

Wie TypeScript die Entwicklung verlangsamt


Es wird einige Zeit dauern, bis die Eingabe unterstützt wird. Es hat nicht die beste UX - manchmal gibt es völlig unverständliche Fehler, wie bei jedem anderen Typsystem, wie in Flow oder Haskell.
Je ausdrucksvoller das System ist, desto schwieriger ist der Fehler.
Der Wert des Typsystems besteht darin, dass es eine schnelle Rückmeldung gibt, wenn Fehler auftreten. Das System zeigt Fehler an und benötigt weniger Zeit, um sie zu finden und zu korrigieren. Wenn Sie weniger Zeit mit der Korrektur von Fehlern verbringen, erhalten mehr Architekturlösungen mehr Aufmerksamkeit. Typen verlangsamen die Entwicklung nicht, wenn Sie lernen, mit ihnen zu arbeiten.

++. - , , (25 26 ) - (27 — 10 ).

(5900 ), ++ IT-, .

AvitoTech FrontendConf 2019 . youtube- telegram @FrontendConfChannel, .

All Articles