TypeScript avançado

Mergulho livre - mergulho sem mergulho. O mergulhador sente a lei de Arquimedes: ele desloca uma certa quantidade de água, o que o empurra de volta. Portanto, os primeiros metros são os mais difíceis, mas a força de pressão da coluna de água acima de você começa a ajudar a se mover mais fundo. Esse processo lembra o aprendizado e o mergulho nos sistemas do tipo TypeScript - torna-se um pouco mais fácil à medida que você mergulha. Mas não devemos esquecer de emergir no tempo.


Foto de One Ocean One Breath .

Mikhail Bashurov (saitonakamura) - Engenheiro de front-end sênior do WiseBits, fã do TypeScript e mergulhador amador. As analogias de aprender TypeScript e mergulhar profundamente não são acidentais. Michael lhe dirá o que são uniões discriminadas, como usar a inferência de tipo, por que você precisa de compatibilidade nominal e de marca. Prenda a respiração e mergulhe.

Apresentação e links para o GitHub com código de exemplo aqui .

Tipo de trabalho


Os tipos de soma soam algébricos - vamos tentar descobrir o que é. Eles são chamados de variante no ReasonML, união marcada em Haskell e união discriminada em F # e TypeScript.

A Wikipedia fornece a seguinte definição: "O tipo de valor é a soma dos tipos de obras".
- Obrigado capitão!

A definição é totalmente correta, mas inútil, então vamos entender. Vamos do privado e começamos com o tipo de trabalho.

Suponha que tenhamos um tipo Task. Tem dois campos: ide quem o criou.

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

Este tipo de trabalho é interseção ou interseção . Isso significa que podemos escrever o mesmo código que cada campo individualmente.

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

Na álgebra de proposições, isso é chamado multiplicação lógica: é a operação “AND” ou “AND”. O tipo de produto é o produto lógico desses dois elementos.
Um objeto com um conjunto de campos pode ser expresso por meio de um produto lógico.
O tipo de trabalho não se limita aos campos. Imagine que amamos o Prototype e decidimos que estamos perdendo leftPad.

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

Adicione-o a String.prototype. Para expressar em tipos, pegamos string e uma função que aceita os valores necessários. O método e a string são a interseção.

Uma associação


A união - união - é útil, por exemplo, para representar o tipo de uma variável que define a largura de um elemento no CSS: uma sequência de 10 pixels ou um valor absoluto. A especificação CSS é realmente muito mais complicada, mas, para simplificar, vamos deixar assim.

type Width = string | number

Um exemplo é mais complicado. Suponha que tenhamos uma tarefa. Pode ser em três estados: recém-criado, aceito no trabalho e concluído.

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

No primeiro e no segundo estados, a tarefa ide quem a criou, e no terceiro, a data de conclusão é exibida. Aqui a união aparece - isto ou aquilo.

Nota . Em vez disso, typevocê pode usar interface. Mas existem nuances. O resultado da união e interseção é sempre um tipo. Você pode expressar interseção através extends, mas união através interfacenão pode. É typeapenas mais curto.

interfaceajuda apenas a mesclar declarações. Os tipos de biblioteca sempre precisam ser expressos interface, porque isso permitirá que outros desenvolvedores a expandam com seus campos. Então, tipificado, por exemplo, plugins jQuery.

Tipo de compatibilidade


Mas há um problema - no TypeScript, compatibilidade de tipo estrutural . Isso significa que, se o primeiro e o segundo tipos tiverem o mesmo conjunto de campos, serão dois do mesmo tipo. Por exemplo, temos 10 tipos com nomes diferentes, mas com uma estrutura idêntica (com os mesmos campos). Se fornecermos qualquer um dos 10 tipos para uma função que aceite um deles, o TypeScript não se importará.

Em Java ou C #, pelo contrário, um sistema de compatibilidade nominal . Escreveremos as mesmas 10 classes com campos idênticos e uma função que leva uma delas. Uma função não aceitará outras classes se elas não forem descendentes diretos do tipo a que a função se destina.

O problema de compatibilidade estrutural é solucionado adicionando status ao campo: enumoustring. Mas os tipos de soma são uma maneira de expressar o código mais semanticamente do que como ele é armazenado no banco de dados.

Por exemplo, abordaremos o ReasonML. É assim que esse tipo fica lá.

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

Os mesmos campos, exceto que, em intvez disso number. Se você olhar atentamente, notamos Initial, InWorke Finished. Eles não estão localizados à esquerda, no nome do tipo, mas à direita na definição. Esta não é apenas uma linha no título, mas parte de um tipo separado, para que possamos distinguir a primeira da segunda.

Corte de vida . Para tipos genéricos (como as entidades do seu domínio), crie um arquivo global.d.tse adicione todos os tipos. Eles serão visíveis automaticamente em toda a base de código TypeScript sem a necessidade de importação explícita. Isso é conveniente para a migração, porque você não precisa se preocupar com o local dos tipos.

Vamos ver como fazer isso, usando o exemplo do código Redux primitivo.

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
}

Reescreva o código no TypeScript. Vamos começar renomeando o arquivo - de .jspara .ts. Ative todas as opções de estado e a opção noImplicitAny. Esta opção encontra funções nas quais o tipo de parâmetro não está especificado e é útil no estágio de migração.

Tipável Statecampo add: isFetching, Task(que não pode ser) e Error.

type Task = { title: string }

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

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

Nota. Lifehack apareceu no TypeScript Deep Dive de Basarat Ali Syed . É um pouco datado, mas ainda é útil para quem deseja mergulhar no TypeScript. Leia sobre o estado atual do TypeScript no blog de Marius Schulz. Ele escreve sobre novos recursos e truques. Também estude os logs de alterações , não há apenas informações sobre atualizações, mas também como usá-las.

Ações restantes. Digitaremos e declararemos cada um deles.

type FetchAction = {
    type: "TASK_FETCH"
}

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

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

type Actions = FetchAction | SuccessAction | FailAction

O campo typepara todos os tipos é diferente, não é apenas uma sequência, mas uma sequência específica - uma sequência literal . Este é o próprio discriminador - a marca pela qual distinguimos todos os casos. Temos uma união discriminada e, por exemplo, podemos entender o que está na carga útil.

Atualize o código Redux original:

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
}

Há perigo aqui se escrevermos string...
type FetchAction = {
    type: string
}
... então tudo vai quebrar, porque não é mais um discriminador.

Nesta ação, o tipo pode ser qualquer string e não um discriminador específico. Depois disso, o TypeScript não poderá distinguir uma ação da outra e encontrar erros. Portanto, deve haver exatamente uma string literal. Além disso, podemos adicionar este projeto: type ActionType = Actions["type"].

Três de nossas opções aparecerão nas instruções.



Se você escrever ...
type FetchAction = {
    type: string
}
... as solicitações serão simples string, porque todas as outras linhas não são mais importantes.



Todos nós digitamos e obtivemos o tipo de valor.

Verificação exaustiva


Imagine uma situação hipotética em que o tratamento de erros não seja adicionado ao código.

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
}

Aqui, uma verificação exaustiva nos ajudará - outra propriedade útil, como soma . Esta é uma oportunidade para verificar se processamos todos os casos possíveis.

Adicionar uma variável após a instrução switch: const exhaustiveCheck: never = action.

nunca é um tipo interessante:

  • se é o resultado de um retorno da função, a função nunca termina corretamente (por exemplo, sempre gera um erro ou é executada sem fim);
  • se for um tipo, esse tipo não poderá ser criado sem nenhum esforço extra.

Agora, o compilador indicará o erro "O tipo 'FailAction' não pode ser atribuído ao tipo 'nunca'". Temos três tipos possíveis de ações, das quais não processamos "TASK_FAIL"- é isso FailAction. Mas para nunca, não é "atribuível".

Vamos adicionar o processamento "TASK_FAIL", e não haverá mais erros. Processado "TASK_FETCH"- retornado, processado "TASK_SUCCESS"- retornado, processado "TASK_FAIL". Quando processamos as três funções, qual poderia ser a ação? Nada - never.

Se você adicionar outra ação, o compilador dirá quais não foram processadas. Isso ajudará se você quiser responder a todas as ações e, se for apenas seletivo, não.

Primeira dica

Tentar muito.
No início, será difícil mergulhar: leia sobre o tipo de soma, depois sobre produtos, tipos de dados algébricos etc. na cadeia. Terá que fazer um esforço.

Quando mergulhamos mais fundo, a pressão da coluna de água acima de nós aumenta. A uma certa profundidade, a força de flutuação e a pressão da água acima de nós são equilibradas. Esta é uma zona de "flutuabilidade neutra" na qual estamos em um estado de ausência de peso. Nesse estado, podemos recorrer a outros tipos de sistemas ou idiomas.

Sistema do tipo nominal


Na Roma antiga, os habitantes tinham três componentes do nome: sobrenome, nome e "apelido". O nome em si é "nomen". Daí vem o sistema do tipo nominal - "nominal". Às vezes é útil.

Por exemplo, temos um código de função.

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

A configuração vem na API: GITHUB_URL=https://api.github.com. O método githubUrlAPI extrai os repositórios nos quais colocamos um asterisco. E se nodeEnv = "production", então, registra essa chamada, por exemplo, para métricas.

Para a função que queremos desenvolver (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
}

A função já sabe como exibir dados e, se não houver, um carregador. Resta adicionar uma chamada de API e preencher os dados.

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

Mas se rodarmos esse código, tudo cairá. No painel do desenvolvedor, descobrimos que ele está fetchacessando o endereço '/users/saitonakamura/starred'- o githubUrl desapareceu em algum lugar. Acontece que tudo por causa do design estranho - nodeEnvvem primeiro. Obviamente, pode ser tentador mudar tudo, mas a função pode ser usada em outros lugares na base de código.

Mas e se o compilador solicitar isso com antecedência? Então você não precisa passar por todo o ciclo de inicialização, detectando um erro ou procurando os motivos.

Branding


O TypeScript possui um hack para esses tipos de marca. Criar Brand, B(string), Te dois tipos. Vamos criar o mesmo tipo para 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

Mas temos um erro.



Agora githubUrlé nodeEnvimpossível atribuir um ao outro, porque esses são tipos nominais. Discreto, mas nominal. Agora não podemos trocá-los aqui - nós os mudamos em outra parte do código.

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

Agora está tudo bem - eles têm marcas primitivas . A marca é útil quando vários argumentos (cadeias, números) são encontrados. Eles têm uma certa semântica (coordenadas x, y) e não devem ser confundidos. É conveniente quando o compilador diz que eles estão confusos.

Mas existem dois problemas. Primeiro, o TypeScript não possui tipos nominais nativos. Mas há esperança de que seja resolvido, uma discussão sobre esse problema está em andamento no repositório de idiomas .

O segundo problema é "como", não há garantias de que o GITHUB_URLlink não esteja quebrado.

Assim como com NODE_ENV. Provavelmente, queremos não apenas algumas cordas, mas "production"ou "development".

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

Tudo isso precisa ser verificado. Refiro-lhe designers inteligentes e o relatório de Sergey Cherepanov, " Criando um domínio com TypeScript em um estilo funcional ".

Segunda e terceira dicas

Cuidado.
Às vezes, pare e olhe em volta: em outros idiomas, estruturas, sistemas de tipos. Aprenda novos princípios e lições.

Quando passamos o ponto de "flutuabilidade neutra", a água pressiona com mais força e nos puxa para baixo.
relaxar.
Mergulhe mais fundo e deixe o TypeScript fazer seu trabalho.

O que o TypeScript pode fazer


TypeScript pode imprimir tipos .

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
}

Existe o TypeScript para isso ReturnType- ele obtém o valor de retorno da função:
type FetchAction = ReturnType<typeof createFetch>
Nele passamos o tipo de função. Simplesmente não podemos escrever uma função: para pegar um tipo de uma função ou variável, precisamos escrever typeof.

Vemos nas dicas type: string.



Isso é ruim - o discriminador será interrompido porque há um objeto literal.

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

Quando criamos um objeto em JavaScript, ele é mutável por padrão. Isso significa que em um objeto com um campo e uma sequência, podemos alterar a sequência posteriormente para outra. Portanto, o TypeScript estende uma sequência específica para qualquer sequência de objetos mutáveis.

Precisamos ajudar o TypeScript de alguma forma. Existe como const para isso.

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

Add - string desaparecerá imediatamente nos prompts. Podemos escrever isso não apenas na linha, mas em geral todo o literal.

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

Em seguida, o tipo (e todos os campos) se tornará somente leitura.

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

Isso é útil porque é improvável que você altere a mutabilidade de sua ação. Portanto, adicionamos como const em todos os lugares.

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
}

Todas as ações que digitaram o código foram reduzidas e adicionadas como const. TypeScript entendeu tudo o resto.

TypeScript pode gerar estado . É representado por uma união no código acima com três estados possíveis de isFetching: true, false ou Task.

Use o tipo State = ReturnType. Os prompts do TypeScript indicam que há uma dependência circular.



Encurtar.

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
}

Stateparou de xingar, mas agora ele está any, porque temos uma dependência cíclica. Digitamos o argumento.

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
}

A conclusão está pronta.



A conclusão é semelhante ao que tínhamos originalmente: true, false, Task. Existem campos de lixo aqui Error, mas com o tipo undefined- o campo parece estar lá, mas parece que não.

Quarta dica

Não excesso de trabalho.
Se você relaxar e mergulhar muito fundo, pode não haver oxigênio suficiente para voltar.

O treinamento também: se você estiver muito imerso na tecnologia e decidir aplicá-la em qualquer lugar, provavelmente encontrará erros, os motivos pelos quais você não conhece. Isso causará rejeição e não será mais necessário usar tipos estáticos. Tente avaliar sua força.

Como o TypeScript retarda o desenvolvimento


Levará algum tempo para suportar a digitação. Ele não possui o melhor UX - às vezes fornece erros completamente incompreensíveis, como qualquer outro sistema de tipos, como no Flow ou Haskell.
Quanto mais expressivo o sistema, mais difícil o erro.
O valor do sistema de tipos é que ele fornece feedback rápido quando ocorrem erros. O sistema exibirá erros e levará menos tempo para encontrá-los e corrigi-los. Se você gastar menos tempo corrigindo erros, mais soluções de arquitetura receberão mais atenção. Os tipos não atrasam o desenvolvimento se você aprender a trabalhar com eles.

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

(5900 ), ++ IT-, .

AvitoTech FrontendConf 2019 . youtube- telegram @FrontendConfChannel, .

All Articles