TypeScript avancé

Apnée - plongée sous-marine sans plongée sous-marine. Le plongeur ressent la loi d'Archimède: il déplace une certaine quantité d'eau, ce qui le repousse. Par conséquent, les premiers mètres sont les plus durs, mais la force de pression de la colonne d'eau au-dessus de vous commence à se déplacer plus profondément. Ce processus rappelle l'apprentissage et la plongée dans les systèmes de type TypeScript - il devient un peu plus facile lorsque vous plongez. Mais nous ne devons pas oublier d'émerger dans le temps.


Photo de One Ocean One Breath .

Mikhail Bashurov (saitonakamura) - Senior Frontend Engineer chez WiseBits, fan de TypeScript et apnéiste amateur. Les analogies de l'apprentissage de TypeScript et de la plongée en profondeur ne sont pas accidentelles. Michael vous dira ce que sont les unions discriminées, comment utiliser l'inférence de type, pourquoi vous avez besoin d'une compatibilité nominale et d'une image de marque. Retenez votre souffle et plongez.

Présentation et liens vers GitHub avec un exemple de code ici .

Type de travail


Les types de sommes semblent algébriques - essayons de comprendre de quoi il s'agit. Ils sont appelés variante dans ReasonML, union étiquetée dans Haskell et union discriminée dans F # et TypeScript.

Wikipedia donne la définition suivante: "Le type de montant est la somme des types d'œuvres".
- Merci capitaine!

La définition est tout à fait correcte, mais inutile, alors comprenons. Allons du privé et commençons par le type de travail.

Supposons que nous ayons un type Task. Il a deux champs: idet qui l'a créé.

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

Ce type de travail est intersection ou intersection . Cela signifie que nous pouvons écrire le même code que chaque champ individuellement.

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

Dans l'algèbre des propositions, cela s'appelle la multiplication logique: c'est l'opération «ET» ou «ET». Le type de produit est le produit logique de ces deux éléments.
Un objet avec un ensemble de champs peut être exprimé via un produit logique.
Le type de travail n'est pas limité aux champs. Imaginez que nous aimons Prototype et avons décidé que nous manquions leftPad.

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

Ajoutez-le à String.prototype. Pour exprimer en types, nous prenons une chaîne et une fonction qui prend les valeurs nécessaires. La méthode et la chaîne sont l'intersection.

Une association


L'union - union - est utile, par exemple, pour représenter le type d'une variable qui définit la largeur d'un élément en CSS: une chaîne de 10 pixels ou une valeur absolue. La spécification CSS est en fait beaucoup plus compliquée, mais pour simplifier, laissons cela de cette façon.

type Width = string | number

Un exemple est plus compliqué. Supposons que nous ayons une tâche. Il peut être dans trois états: juste créé, accepté dans le travail et terminé.

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

Dans les premier et deuxième états, la tâche l'a idet qui l'a créée, et dans le troisième, la date d'achèvement apparaît. Ici, l'union apparaît - ceci ou cela.

Remarque . Au lieu de cela, typevous pouvez utiliser interface. Mais il y a des nuances. Le résultat de l'union et de l'intersection est toujours un type. Vous pouvez exprimer l'intersection à travers extends, mais l'union à travers interfacene le peut pas. C'est typejuste plus court.

interfaceaide uniquement à fusionner les déclarations. Les types de bibliothèques doivent toujours être exprimés à travers interface, car cela permettra à d'autres développeurs de l'étendre avec leurs champs. Donc, typiquement, par exemple, jQuery-plugins.

Compatibilité des types


Mais il y a un problème - dans TypeScript, la compatibilité des types structurels . Cela signifie que si les premier et deuxième types ont le même ensemble de champs, ce sont deux du même type. Par exemple, nous avons 10 types avec des noms différents, mais avec une structure identique (avec les mêmes champs). Si nous fournissons l'un des 10 types à une fonction qui accepte l'un d'entre eux, TypeScript ne s'en souciera pas.

En Java ou C #, au contraire, un système de compatibilité nominale . Nous allons écrire les 10 mêmes classes avec des champs identiques et une fonction qui en prend une. Une fonction n'acceptera pas d'autres classes si elles ne sont pas des descendants directs du type auquel la fonction est destinée.

Le problème de compatibilité structurelle est résolu en ajoutant un statut au champ: enumoustring. Mais les types de somme sont un moyen d'exprimer du code plus sémantiquement que la façon dont il est stocké dans la base de données.

Pour un exemple, nous nous adresserons à ReasonML. Voici à quoi ressemble ce type.

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

Les mêmes champs, sauf qu'à la intplace number. Si vous regardez attentivement, nous notons Initial, InWorket Finished. Ils ne sont pas situés à gauche, dans le nom du type, mais à droite dans la définition. Ce n'est pas seulement une ligne dans le titre, mais une partie d'un type distinct, nous pouvons donc distinguer le premier du second.

Piratage de la vie . Pour les types génériques (comme vos entités de domaine), créez un fichier global.d.tset ajoutez-y tous les types. Ils seront automatiquement visibles dans toute la base de code TypeScript sans avoir besoin d'une importation explicite. Cela est pratique pour la migration, car vous n'avez pas à vous soucier de l'emplacement des types.

Voyons comment procéder en utilisant l'exemple du code Redux primitif.

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
}

Réécrivez le code en TypeScript. Commençons par renommer le fichier - de .jsà .ts. Activez toutes les options d'état et l'option noImplicitAny. Cette option recherche les fonctions dans lesquelles le type de paramètre n'est pas spécifié et est utile au stade de la migration.

Typable State: ajouter un champ isFetching, Task(qui ne peut pas l'être) et Error.

type Task = { title: string }

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

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

Remarque. Lifehack a jeté un coup d'œil à la plongée profonde TypeScript de Basarat Ali Syed . C'est un peu daté, mais toujours utile pour ceux qui veulent plonger dans TypeScript. Découvrez l'état actuel de TypeScript sur le blog de Marius Schulz. Il écrit sur de nouvelles fonctionnalités et astuces. Étudiez également les journaux des modifications , il n'y a pas seulement des informations sur les mises à jour, mais aussi comment les utiliser.

Actions restantes. Nous taperons et déclarerons chacun d'eux.

type FetchAction = {
    type: "TASK_FETCH"
}

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

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

type Actions = FetchAction | SuccessAction | FailAction

Le champ typepour tous les types est différent, ce n'est pas seulement une chaîne, mais une chaîne spécifique - un littéral de chaîne . C'est le discriminateur même - l'étiquette par laquelle nous distinguons tous les cas. Nous avons obtenu un syndicat discriminé et, par exemple, nous pouvons comprendre ce qui est dans la charge utile.

Mettez à jour le code Redux d'origine:

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
}

Il y a un danger ici si nous écrivons string...
type FetchAction = {
    type: string
}
... alors tout va se casser, car ce n'est plus un discriminateur.

Dans cette action, le type peut être n'importe quelle chaîne et non un discriminateur spécifique. Après cela, TypeScript ne pourra pas distinguer une action d'une autre et trouver des erreurs. Par conséquent, il doit y avoir exactement un littéral de chaîne. De plus, nous pouvons ajouter cette conception: type ActionType = Actions["type"].

Trois de nos options apparaîtront dans les invites.



Si vous écrivez ...
type FetchAction = {
    type: string
}
... alors les invites seront simples string, car toutes les autres lignes ne sont plus importantes.



Nous avons tous tapé et obtenu le type de montant.

Vérification exhaustive


Imaginez une situation hypothétique où la gestion des erreurs n'est pas ajoutée au 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
}

Ici, une vérification exhaustive nous aidera - une autre propriété utile comme la somme . C'est l'occasion de vérifier que nous avons traité tous les cas possibles.

Ajouter une variable après l'instruction switch: const exhaustiveCheck: never = action.

n'est jamais un type intéressant:

  • si elle est le résultat d'un retour de fonction, alors la fonction ne se termine jamais correctement (par exemple, elle renvoie toujours une erreur ou est exécutée sans fin);
  • s'il s'agit d'un type, ce type ne peut pas être créé sans effort supplémentaire.

Maintenant, le compilateur indiquera l'erreur «Le type« FailAction »n'est pas attribuable au type« jamais »». Nous avons trois types d'actions possibles, dont nous n'avons pas traité "TASK_FAIL"- c'est le cas FailAction. Mais à jamais, ce n'est pas «assignable».

Ajoutons le traitement "TASK_FAIL", et il n'y aura plus d'erreurs. Traité "TASK_FETCH"- renvoyé, traité "TASK_SUCCESS"- retourné, traité "TASK_FAIL". Lorsque nous avons traité les trois fonctions, quelle pourrait être l'action? Rien - never.

Si vous ajoutez une autre action, le compilateur vous indiquera celles qui ne sont pas traitées. Cela vous aidera si vous souhaitez répondre à toutes les actions, et si ce n'est qu'à une sélection, alors non.

Premier conseil

Faire un effort.
Au début, il sera difficile de plonger: lisez le type de somme, puis les produits, les types de données algébriques, etc. dans la chaîne. Faudra faire un effort.

Lorsque nous plongeons plus profondément, la pression de la colonne d'eau au-dessus de nous augmente. À une certaine profondeur, la force de flottabilité et la pression d'eau au-dessus de nous sont équilibrées. Il s'agit d'une zone de "flottabilité neutre", dans laquelle nous sommes en apesanteur. Dans cet état, nous pouvons nous tourner vers d'autres types de systèmes ou langages.

Système de type nominal


Dans la Rome antique, les habitants avaient trois composantes du nom: nom, prénom et "surnom". Le nom lui-même est «nomen». De là vient le système de type nominal - "nominal". C'est parfois utile.

Par exemple, nous avons un tel code de fonction.

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());
}

La configuration est sur l'API: GITHUB_URL=https://api.github.com. La méthode githubUrlAPI extrait les référentiels sur lesquels nous mettons un astérisque. Et si nodeEnv = "production", il enregistre cet appel, par exemple, pour les mesures.

Pour la fonction que nous voulons développer (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
}

La fonction sait déjà comment afficher les données, et s'il n'y en a pas, alors un chargeur. Il reste à ajouter un appel API et à remplir les données.

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

Mais si nous exécutons ce code, tout tombera. Dans le panneau du développeur, nous constatons qu'il fetchaccède à l'adresse '/users/saitonakamura/starred'- githubUrl a disparu quelque part. Il s'avère que tout cela en raison de la conception étrange - nodeEnvvient en premier. Bien sûr, il peut être tentant de tout changer, mais la fonction peut être utilisée à d'autres endroits de la base de code.

Mais que se passe-t-il si le compilateur le demande à l'avance? Ensuite, vous n'avez pas à passer par tout le cycle de lancement, à détecter une erreur ou à rechercher les raisons.

l'image de marque


TypeScript a un hack pour cela - les types de marque. Créez Brand, B(chaîne) Tet deux types. Nous allons créer le même type pour 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

Mais nous obtenons une erreur.



Il githubUrlest maintenant nodeEnvimpossible de s’attribuer mutuellement, car ce sont des types nominaux. Discret, mais nominal. Maintenant, nous ne pouvons pas les échanger ici - nous les changeons dans une autre partie du code.

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

Maintenant, tout va bien - ils ont des primitives de marque . Le branding est utile lorsque plusieurs arguments (chaînes, nombres) sont rencontrés. Ils ont une certaine sémantique (coordonnées x, y), et ils ne doivent pas être confondus. C'est pratique lorsque le compilateur vous dit qu'ils sont confus.

Mais il y a deux problèmes. Tout d'abord, TypeScript n'a pas de types nominaux natifs. Mais il y a de l'espoir qu'il sera résolu, une discussion de ce problème est en cours dans le référentiel de langue .

Le deuxième problème est «comme», il n'y a aucune garantie que le GITHUB_URLlien n'est pas rompu.

Ainsi qu'avec NODE_ENV. Très probablement, nous voulons non seulement une chaîne, mais "production"ou "development".

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

Tout cela doit être vérifié. Je vous réfère aux concepteurs intelligents et au rapport de Sergey Cherepanov, " Conception d'un domaine avec TypeScript dans un style fonctionnel ".

Deuxième et troisième conseils

Fais attention.
Parfois, arrêtez-vous et regardez autour de vous: à d'autres langages, frameworks, systèmes de types. Apprenez de nouveaux principes et apprenez des leçons.

Lorsque nous passons le point de "flottabilité neutre", l'eau presse plus fort et nous tire vers le bas.
Se détendre.
Plongez plus profondément et laissez TypeScript faire son travail.

Ce que TypeScript peut faire


TypeScript peut imprimer des types .

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
}

Il y a TypeScript pour cela ReturnType- il obtient la valeur de retour de la fonction:
type FetchAction = ReturnType<typeof createFetch>
On y passe le type de fonction. Nous ne pouvons tout simplement pas écrire une fonction: pour prendre un type dans une fonction ou une variable, nous devons écrire typeof.

On voit dans les astuces type: string.



C'est mauvais - le discriminateur se cassera parce qu'il y a un objet littéral.

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

Lorsque nous créons un objet en JavaScript, il est modifiable par défaut. Cela signifie que dans un objet avec un champ et une chaîne, nous pouvons plus tard changer la chaîne en une autre. Par conséquent, TypeScript étend une chaîne spécifique à n'importe quelle chaîne pour les objets mutables.

Nous devons en quelque sorte aider TypeScript. Il y a autant de const pour cela.

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

Ajouter - la chaîne disparaîtra immédiatement dans les invites. Nous pouvons écrire ceci non seulement à travers la ligne, mais en général tout le littéral.

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

Le type (et tous les champs) deviendra alors en lecture seule.

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

Ceci est utile car il est peu probable que vous changiez la mutabilité de votre action. Par conséquent, nous ajoutons comme const partout.

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
}

Toutes les actions de saisie de code ont été réduites et ajoutées en tant que const. TypeScript a tout compris.

TypeScript peut afficher l'état . Il est représenté par l'union dans le code ci-dessus avec trois états possibles de isFetching: true, false ou Task.

Utilisez type State = ReturnType. Les invites TypeScript indiquent qu'il existe une dépendance circulaire.



Raccourcir.

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
}

Statecessé de maudire, mais maintenant il l'est any, car nous avons une dépendance cyclique. Nous tapons l'argument.

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
}

La conclusion est prête.



La conclusion est similaire à ce que nous avions à l' origine: true, false, Task. Il y a des champs poubelles ici Error, mais avec le type undefined- le champ semble être là, mais il ne semble pas.

Quatrième astuce

Ne surchargez pas.
Si vous vous détendez et plongez trop profondément, il se peut qu'il n'y ait pas assez d'oxygène pour revenir en arrière.

La formation aussi: si vous êtes trop immergé dans la technologie et décidez de l'appliquer partout, vous rencontrerez très probablement des erreurs dont vous ne connaissez pas les raisons. Cela entraînera un rejet et ne voudra plus utiliser de types statiques. Essayez d'évaluer votre force.

Comment TypeScript ralentit le développement


La prise en charge de la saisie prendra un certain temps. Il n'a pas le meilleur UX - il donne parfois des erreurs complètement incompréhensibles, comme tout autre type de système, comme dans Flow ou Haskell.
Plus le système est expressif, plus l'erreur est difficile.
La valeur du système de type est qu'il fournit une rétroaction rapide lorsque des erreurs se produisent. Le système affichera les erreurs et prendra moins de temps pour les trouver et les corriger. Si vous passez moins de temps à corriger les erreurs, plus de solutions architecturales recevront plus d'attention. Les types ne ralentissent pas le développement si vous apprenez à travailler avec eux.

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

(5900 ), ++ IT-, .

AvitoTech FrontendConf 2019 . youtube- telegram @FrontendConfChannel, .

All Articles