TypeScript avanzado

Apnea libre - buceo sin buceo. El buzo siente la ley de Arquímedes: desplaza una cierta cantidad de agua, lo que lo empuja hacia atrás. Por lo tanto, los primeros metros son los más difíciles, pero luego la fuerza de presión de la columna de agua sobre usted comienza a ayudar a moverse más profundo. Este proceso recuerda el aprendizaje y la inmersión en sistemas de tipo TypeScript: se vuelve un poco más fácil a medida que bucea. Pero no debemos olvidar emerger a tiempo.


Foto de One Ocean One Breath .

Mikhail Bashurov (saitonakamura) - Ingeniero frontend senior en WiseBits, fanático de TypeScript y freediver aficionado. Las analogías de aprender TypeScript y profundizar no son accidentales. Michael le dirá qué son las uniones discriminadas, cómo usar la inferencia de tipos, por qué necesita compatibilidad nominal y marca. Aguanta la respiración y sumérgete.

Presentación y enlaces a GitHub con código de muestra aquí .

Tipo de trabajo


Los tipos de sumas suenan algebraicas; tratemos de descubrir qué es. Se llaman variantes en ReasonML, unión etiquetada en Haskell y unión discriminada en F # y TypeScript.

Wikipedia da la siguiente definición: "El tipo de cantidad es la suma de los tipos de obras".
- Gracias capitán!

La definición es completamente correcta, pero inútil, así que comprendamos. Vayamos de lo privado y comencemos con el tipo de trabajo.

Supongamos que tenemos un tipo Task. Tiene dos campos: idy quién lo creó.

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

Este tipo de trabajo es intersección o intersección . Esto significa que podemos escribir el mismo código que cada campo individualmente.

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

En el álgebra de proposiciones, esto se llama multiplicación lógica: esta es la operación "Y" o "Y". El tipo de producto es el producto lógico de estos dos elementos.
Un objeto con un conjunto de campos se puede expresar a través de un producto lógico.
El tipo de trabajo no se limita a los campos. Imagina que amamos Prototype y decidimos que nos estamos perdiendo leftPad.

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

Añádelo a String.prototype. Para expresar en tipos, tomamos una cadena y una función que toma los valores necesarios. El método y la cadena son la intersección.

Una asociación


La unión - unión - es útil, por ejemplo, para representar el tipo de una variable que establece el ancho de un elemento en CSS: una cadena de 10 píxeles o un valor absoluto. La especificación CSS es en realidad mucho más complicada, pero por simplicidad, dejémoslo así.

type Width = string | number

Un ejemplo es más complicado. Supongamos que tenemos una tarea. Puede estar en tres estados: recién creado, aceptado en el trabajo y completado.

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

En el primer y segundo estado, la tarea tiene idy quién la creó, y en el tercero, aparece la fecha de finalización. Aquí aparece la unión, ya sea esto o aquello.

Nota . En cambio, typepuedes usar interface. Pero hay matices. El resultado de la unión y la intersección es siempre un tipo. Puede expresar la intersección a través extends, pero la unión a través interfaceno puede. Es typesolo más corto.

interfaceayuda solo a fusionar declaraciones. Los tipos de biblioteca siempre deben expresarse interface, ya que esto permitirá que otros desarrolladores lo expandan con sus campos. Así tipificado, por ejemplo, jQuery-plugins.

Compatibilidad de tipo


Pero hay un problema: en TypeScript, compatibilidad de tipo estructural . Esto significa que si el primer y el segundo tipo tienen el mismo conjunto de campos, entonces estos son dos del mismo tipo. Por ejemplo, tenemos 10 tipos con diferentes nombres, pero con una estructura idéntica (con los mismos campos). Si proporcionamos cualquiera de los 10 tipos a una función que acepta uno de ellos, a TypeScript no le importará.

En Java o C #, por el contrario, un sistema de compatibilidad nominal . Escribiremos las mismas 10 clases con campos idénticos y una función que tome una de ellas. Una función no aceptará otras clases si no son descendientes directos del tipo para el que está destinada la función.

El problema de compatibilidad estructural se aborda agregando estado al campo: enumostring. Pero los tipos de suma son una forma de expresar código más semánticamente que cómo se almacena en la base de datos.

Por ejemplo, nos dirigiremos a ReasonML. Así es como se ve este tipo allí.

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

Los mismos campos, excepto que en su intlugar number. Si nos fijamos bien, observamos Initial, InWorky Finished. No se encuentran a la izquierda, en el nombre del tipo, sino a la derecha en la definición. Esto no es solo una línea en el título, sino parte de un tipo separado, por lo que podemos distinguir el primero del segundo.

Hack de la vida . Para los tipos genéricos (como las entidades de su dominio) cree un archivo global.d.tsy agréguele todos los tipos. Serán visibles automáticamente en toda la base de código TypeScript sin la necesidad de una importación explícita. Esto es conveniente para la migración porque no tiene que preocuparse por dónde colocar los tipos.

Veamos cómo hacer esto, usando el ejemplo del código primitivo de Redux.

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
}

Reescribe el código en TypeScript. Comencemos renombrando el archivo, de .jsa .ts. Active todas las opciones de estado y la opción noImplicitAny. Esta opción encuentra funciones en las que no se especifica el tipo de parámetro y es útil en la etapa de migración.

Typable State: agregar campo isFetching, Task(que no puede ser) y 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 se asomó en la inmersión profunda TypeScript de Basarat Ali Syed . Está un poco anticuado, pero sigue siendo útil para aquellos que quieren sumergirse en TypeScript. Lea sobre el estado actual de TypeScript en el blog de Marius Schulz. Escribe sobre nuevas características y trucos. También estudie los registros de cambios , no solo hay información sobre las actualizaciones, sino también cómo usarlas.

Acciones restantes. Escribiremos y declararemos cada uno de ellos.

type FetchAction = {
    type: "TASK_FETCH"
}

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

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

type Actions = FetchAction | SuccessAction | FailAction

El campo typepara todos los tipos es diferente, no es solo una cadena, sino una cadena específica: un literal de cadena . Este es el discriminador : la etiqueta por la cual distinguimos todos los casos. Tenemos un sindicato discriminado y, por ejemplo, podemos entender lo que hay en la carga útil.

Actualice el código original de Redux:

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
}

Hay peligro aquí si escribimos string...
type FetchAction = {
    type: string
}
... entonces todo se romperá, porque ya no es un discriminador.

En esta acción, el tipo puede ser cualquier cadena y no un discriminador específico. Después de eso, TypeScript no podrá distinguir una acción de otra y encontrar errores. Por lo tanto, debe haber exactamente un literal de cadena. Por otra parte, podemos añadir este diseño type ActionType = Actions["type"].

Tres de nuestras opciones aparecerán en las indicaciones.



Si tú escribes ...
type FetchAction = {
    type: string
}
... entonces las indicaciones serán simples string, porque todas las demás líneas ya no son importantes.



Todos hemos escrito y tenemos el tipo de cantidad.

Comprobación exhaustiva


Imagine una situación hipotética donde el manejo de errores no se agrega al 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
}

Aquí la comprobación exhaustiva nos ayudará , otra propiedad útil como la suma . Esta es una oportunidad para verificar que hemos procesado todos los casos posibles.

Añadir una variable después de la sentencia switch: const exhaustiveCheck: never = action.

nunca es un tipo interesante:

  • si es el resultado del retorno de una función, la función nunca termina correctamente (por ejemplo, siempre arroja un error o se ejecuta sin fin);
  • si es un tipo, este tipo no se puede crear sin ningún esfuerzo adicional.

Ahora el compilador indicará el error "Escriba 'FailAction' no es asignable para escribir 'nunca'". Tenemos tres tipos posibles de acciones, de las cuales no hemos procesado "TASK_FAIL", esto es FailAction. Pero para nunca, no es "asignable".

Agreguemos procesamiento "TASK_FAIL", y no habrá más errores. Procesado "TASK_FETCH"- devuelto, procesado "TASK_SUCCESS"- devuelto, procesado "TASK_FAIL". Cuando procesamos las tres funciones, ¿cuál podría ser la acción? Nada - never.

Si agrega otra acción, el compilador le dirá cuáles no se procesan. Esto ayudará si desea responder a todas las acciones, y solo si es selectivo, entonces no.

Primer consejo

Intenta fuerte.
Al principio será difícil bucear: lea sobre el tipo de suma, luego sobre productos, tipos de datos algebraicos, etc. en la cadena. Tendrá que hacer un esfuerzo.

Cuando nos sumergimos más profundamente, la presión de la columna de agua sobre nosotros aumenta. A cierta profundidad, la fuerza de flotación y la presión del agua sobre nosotros están equilibradas. Esta es una zona de "flotabilidad neutral", en la cual estamos en un estado de ingravidez. En este estado, podemos recurrir a otros sistemas de tipos o idiomas.

Sistema de tipo nominal


En la antigua Roma, los habitantes tenían tres componentes del nombre: apellido, nombre y "apodo". El nombre en sí es "nomen". De esto proviene el sistema de tipo nominal - "nominal". A veces es útil.

Por ejemplo, tenemos dicho código de función.

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 configuración entra en la API: GITHUB_URL=https://api.github.com. El método githubUrlAPI extrae los repositorios en los que ponemos un asterisco. Y si nodeEnv = "production", entonces registra esta llamada, por ejemplo, para métricas.

Para la función que queremos desarrollar (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 función ya sabe cómo mostrar datos, y si no hay ninguno, entonces un cargador. Queda por agregar una llamada a la API y completar los datos.

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

Pero si ejecutamos este código, todo se caerá. En el panel de desarrolladores, encontramos que está fetchaccediendo a la dirección '/users/saitonakamura/starred': githubUrl ha desaparecido en algún lugar. Resulta que todo por el extraño diseño nodeEnves lo primero. Por supuesto, puede ser tentador cambiarlo todo, pero la función se puede usar en otros lugares de la base del código.

Pero, ¿qué pasa si el compilador solicita esto por adelantado? Entonces no tiene que pasar por todo el ciclo de inicio, detectar un error, buscar los motivos.

Marca


TypeScript tiene un truco para esto: tipos de marca. Crear Brand, B(cadena) Ty dos tipos. Crearemos el mismo 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

Pero tenemos un error.



Ahora githubUrles nodeEnvimposible asignarse entre sí, porque estos son tipos nominales. Discreto, pero nominal. Ahora no podemos intercambiarlos aquí; los cambiamos en otra parte del código.

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

Ahora todo está bien: obtuvieron primitivas de marca . La marca es útil cuando se encuentran varios argumentos (cadenas, números). Tienen una cierta semántica (coordenadas x, y), y no deben confundirse. Es conveniente cuando el compilador le dice que están confundidos.

pero hay dos problemas. Primero, TypeScript no tiene tipos nominales nativos. Pero hay esperanza de que se resuelva, hay una discusión sobre este tema en curso en el repositorio de idiomas .

El segundo problema es "como", no hay garantías de que el GITHUB_URLenlace no esté roto.

Así como con NODE_ENV. Lo más probable es que queramos no solo una cadena, sino "production"o "development".

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

Todo esto necesita ser verificado. Me refiero a los diseñadores inteligentes y al informe de Sergey Cherepanov " Diseño de un dominio con TypeScript en un estilo funcional ".

Segundo y tercer consejos

Ten cuidado.
A veces, deténgase y mire a su alrededor: en otros idiomas, marcos, sistemas de tipos. Aprende nuevos principios y aprende lecciones.

Cuando pasamos el punto de "flotabilidad neutral", el agua presiona más y nos empuja hacia abajo.
Relajarse.
Sumérgete más y deja que TypeScript haga su trabajo.

Qué puede hacer TypeScript


TypeScript puede 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
}

Hay TypeScript para esto ReturnType: obtiene el valor de retorno de la función:
type FetchAction = ReturnType<typeof createFetch>
En ella pasamos el tipo de función. Simplemente no podemos escribir una función: para tomar un tipo de una función o variable, necesitamos escribir typeof.

Vemos en los consejos type: string.



Esto es malo: el discriminador se romperá porque hay un objeto literal.

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

Cuando creamos un objeto en JavaScript, es mutable por defecto. Esto significa que en un objeto con un campo y una cadena, luego podemos cambiar la cadena a otra. Por lo tanto, TypeScript extiende una cadena específica a cualquier cadena para objetos mutables.

Necesitamos ayudar a TypeScript de alguna manera. Hay como constante para esto.

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

Agregar: la cadena desaparecerá inmediatamente en las indicaciones. Podemos escribir esto no solo a través de la línea, sino en general todo el literal.

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

Entonces el tipo (y todos los campos) serán de solo lectura.

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

Esto es útil porque es poco probable que cambie la mutabilidad de su acción. Por lo tanto, agregamos como constante en todas partes.

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 las acciones que escriben código se han reducido y agregado como constante. TypeScript entendió todo lo demás.

TypeScript puede generar estado . Está representado por la unión en el código anterior con tres posibles estados de isFetching: verdadero, falso o Tarea.

Utilice el tipo State = ReturnType. Las solicitudes de TypeScript indican que existe una dependencia circular.



Acortar.

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
}

StateDejó de maldecir, pero ahora lo está any, porque tenemos una dependencia cíclica. Escribimos el 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
}

La conclusión está lista.



La conclusión es similar a lo que teníamos originalmente: true, false, Task. Aquí hay campos de basura Error, pero con el tipo undefined: el campo parece estar allí, pero parece que no.

Cuarto consejo

No trabajes demasiado.
Si te relajas y buceas demasiado profundo, puede que no haya suficiente oxígeno para regresar.

La capacitación también: si está demasiado inmerso en la tecnología y decide aplicarla en todas partes, lo más probable es que encuentre errores, las razones por las que no sabe. Esto causará rechazo y ya no querrá usar tipos estáticos. Intenta evaluar tu fuerza.

Cómo TypeScript ralentiza el desarrollo


Tomará algún tiempo admitir la escritura. No tiene el mejor UX, a veces da errores completamente incomprensibles, como cualquier otro tipo de sistema, como en Flow o Haskell.
Cuanto más expresivo es el sistema, más difícil es el error.
El valor del sistema de tipos es que proporciona una respuesta rápida cuando se producen errores. El sistema mostrará errores y tardará menos tiempo en encontrarlos y corregirlos. Si pasa menos tiempo corrigiendo errores, más soluciones arquitectónicas recibirán más atención. Los tipos no ralentizan el desarrollo si aprende a trabajar con ellos.

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

(5900 ), ++ IT-, .

AvitoTech FrontendConf 2019 . youtube- telegram @FrontendConfChannel, .

All Articles