Transfiera 30,000 líneas de código de Flow a TypeScript

Recientemente trasladamos 30,000 líneas de código JavaScript de nuestro sistema MemSQL Studio de Flow a TypeScript. En este artículo explicaré por qué portamos el código base, cómo sucedió y qué sucedió.

Descargo de responsabilidad: mi objetivo no es criticar a Flow en absoluto. Admiro el proyecto y creo que hay suficiente espacio en la comunidad de JavaScript para ambas opciones de verificación de tipos. Al final, todos elegirán lo que más le convenga. Sinceramente espero que el artículo ayude en esta elección.

Primero, te pondré al día. En MemSQL somos grandes admiradores de la escritura JavaScript estática y fuerte para evitar problemas comunes con la escritura dinámica y débil.

Discurso sobre problemas comunes:

  1. Errores de tipo en tiempo de ejecución debido al hecho de que las diferentes partes del código no coinciden con los tipos implícitos.
  2. Se dedica demasiado tiempo a escribir pruebas para cosas tan triviales como verificar parámetros de tipo (verificar en tiempo de ejecución también aumenta el tamaño del paquete).
  3. Hay una falta de integración editor / IDE, porque sin tipeo estático es mucho más difícil implementar la función Jump to Definition, la refactorización mecánica y otras funciones.
  4. No hay forma de escribir código alrededor de modelos de datos, es decir, primero diseñar tipos de datos y luego el código básicamente "se escribe".

Estos son solo algunos de los beneficios del tipeo estático, que se detallan en un artículo reciente sobre Flow .

A principios de 2016, implementamos tcomb para implementar algún tipo de seguridad en el tiempo de ejecución de uno de nuestros proyectos internos de JavaScript (descargo de responsabilidad: no participé en este proyecto). Aunque la verificación en tiempo de ejecución a veces es útil, ni siquiera proporciona todos los beneficios de la escritura estática (la combinación de escritura estática y verificación de tipos en tiempo de ejecución puede ser adecuada para ciertos casos, io-ts le permite hacer esto con tcomb y TypeScript, aunque nunca lo intenté ) Entendiendo esto, decidimos implementar Flow para otro proyecto que comenzamos en 2016. En ese momento, Flow parecía una gran opción:

  • Apoyo de Facebook, que ha hecho un trabajo increíble desarrollando React y haciendo crecer la comunidad (también desarrollaron React with Flow).
  • Aproximadamente el mismo ecosistema de desarrollo de JavaScript. Daba miedo abandonar Babel por tsc (compilador TypeScript) porque habíamos perdido la flexibilidad de cambiar a otro tipo de verificación (obviamente, la situación ha cambiado desde entonces).
  • No es necesario tipificar toda la base de código (queríamos tener una idea de JavaScript estáticamente tipado antes de incluirlo todo), pero solo una parte de los archivos. Tenga en cuenta que tanto Flow como TypeScript ahora lo permiten.
  • TypeScript (en ese momento) carecía de algunas de las funciones básicas que ahora están disponibles, estos son tipos de búsqueda , parámetros predeterminados para tipos genéricos , etc.

Cuando comenzamos a trabajar en MemSQL Studio a fines de 2017, íbamos a cubrir los tipos de la aplicación completa (está completamente escrita en JavaScript: tanto el frontend como el backend se ejecutan en el navegador). Tomamos Flow como una herramienta que hemos utilizado con éxito en el pasado.

Pero me llamó la atención Babel 7 con soporte TypeScript . Esta versión significaba que cambiar a TypeScript ya no requería una transición a todo el ecosistema de TypeScript, y podía seguir usando Babel para JavaScript. Más importante aún, podríamos usar TypeScript solo para la verificación de tipos , y no como un "lenguaje" completo.

Personalmente, creo que separar la verificación de tipos del generador de código es una forma más elegante de escribir estática (y fuerte) en JavaScript, porque:

  1. Compartimos los problemas de código y mecanografía. Esto reduce las paradas de verificación de tipos y acelera el desarrollo: si por alguna razón la verificación de tipos es lenta, el código se generará correctamente (si usa tsc con Babel, puede configurarlo para que haga lo mismo).
  2. Babel tiene excelentes complementos y características que el generador TypeScript no tiene. Por ejemplo, Babel le permite especificar navegadores compatibles y emitirá automáticamente código para ellos. Esta es una función muy compleja y no tiene sentido apoyarla en dos proyectos diferentes al mismo tiempo.
  3. Me gusta JavaScript como lenguaje de programación (excepto por la falta de tipeo estático), y no tengo idea de cuánto existirá TypeScript, aunque creo en muchos años de ECMAScript. Por lo tanto, prefiero escribir y "pensar" en JavaScript (tenga en cuenta que digo "use Flow" o "use TypeScript" en lugar de "escribir en Flow" o "TypeScript", porque siempre los represento con herramientas, no con lenguajes de programación).

Por supuesto, este enfoque tiene algunas desventajas:

  1. El compilador de TypeScript teóricamente puede realizar optimizaciones basadas en tipos, pero aquí perdemos esa oportunidad.
  2. La configuración del proyecto es un poco más complicada con un aumento en el número de herramientas y dependencias. Creo que este es un argumento relativamente débil: un montón de Babel y Flow nunca nos han defraudado.

TypeScript como alternativa a Flow


Noté un creciente interés en TypeScript en la comunidad JavaScript: tanto en línea como entre los desarrolladores que lo rodean. Por lo tanto, tan pronto como descubrí que Babel 7 es compatible con TypeScript, inmediatamente comencé a estudiar posibles opciones de transición. Además, encontramos algunas de las desventajas de Flow:

  1. Menor calidad de integración editor / IDE (en comparación con TypeScript). Nuclide, el IDE propio de Facebook con la mejor integración, ya está desactualizado.
  2. Una comunidad más pequeña, lo que significa menos definiciones de tipo para diferentes bibliotecas, y son de menor calidad (actualmente el repositorio DefinitelyTyped tiene 19 682 estrellas GitHub, y el repositorio de tipo flujo tiene solo 3070).
  3. Falta de un plan de desarrollo público y poca interacción entre el equipo de Flow en Facebook y la comunidad. Puede leer este comentario de un empleado de Facebook para comprender la situación.
  4. Alto consumo de memoria y fugas frecuentes: para algunos de nuestros desarrolladores, Flow a veces ocupaba casi 10 GB de RAM.

Por supuesto, debe estudiar cómo TypeScript nos conviene. Esta es una pregunta muy compleja: el estudio del tema incluyó una lectura exhaustiva de la documentación, lo que ayudó a comprender que para cada función de Flow hay un TypeScript equivalente. Luego exploré el plan de desarrollo público de TypeScript, y realmente me gustaron las características que están planificadas para el futuro (por ejemplo, derivación parcial de los argumentos de tipo que usamos en Flow).

Transfiera más de 30 mil líneas de código de Flow a TypeScript


Para empezar, debe actualizar Babel de 6 a 7. Esta tarea simple tomó 16 horas-hombre, ya que decidimos actualizar Webpack 3 a 4. Al mismo tiempo. Algunas dependencias obsoletas en nuestro código complicaron la tarea. La gran mayoría de los proyectos de JavaScript no tendrán tales problemas.

Después de eso, reemplazamos el preset de Babel Flow con el nuevo preset de TypeScript, y luego, por primera vez, lanzamos el compilador de TypeScript en todas nuestras fuentes escritas usando Flow. El resultado son 8245 errores de sintaxis (tsc CLI no muestra errores reales para el proyecto hasta que se hayan corregido todos los errores de sintaxis).

Al principio, este número nos asustó (muy), pero rápidamente nos dimos cuenta de que la mayoría de los errores se debían a que TypeScript no admitía archivos .js. Después de estudiar el tema, aprendí que los archivos TypeScript deberían terminar con .ts o .tsx (si tienen JSX). Esto me parece un claro inconveniente. Para no pensar en la presencia / ausencia de JSX, simplemente cambié el nombre de todos los archivos a .tsx.

Quedan unos 4.000 errores de sintaxis. La mayoría de ellos están relacionados con el tipo de importación , que con TypeScript se puede reemplazar simplemente con importación, así como con la diferencia en la designación de objetos ( {||} lugar de {} ). Aplicando rápidamente un par de expresiones regulares, dejamos 414 errores de sintaxis. Todo lo demás tenía que repararse manualmente:

  • El tipo existencial , que usamos para derivar parcialmente los argumentos de un tipo genérico, debe reemplazarse por argumentos explícitos o desconocidos para indicar a TypeScript que algunos argumentos no son importantes.
  • Las teclas de tipo $ y otros tipos de flujo avanzados tienen una sintaxis diferente en TypeScript (por ejemplo, $Shape“” corresponde a Partial“” en TypeScript).

Después de corregir todos los errores de sintaxis, tsc finalmente dijo cuántos errores de tipo real en nuestra base de código son solo alrededor de 1300. Ahora tuvimos que sentarnos y decidir si continuar o no. Después de todo, si la migración lleva semanas, es mejor permanecer en Flow. Sin embargo, decidimos que la transferencia de código requeriría menos de una semana de trabajo por parte de un ingeniero, lo cual es bastante aceptable.

Tenga en cuenta que durante la migración tuve que detener todo el trabajo en esta base de código. Sin embargo, en paralelo puede comenzar nuevos proyectos, pero debe tener en cuenta potencialmente cientos de errores de tipo en el código existente, lo que no es fácil.

¿Qué tipo de errores?


TypeScript y Flow procesan el código JavaScript de muchas maneras. Entonces, Flow es más estricto en relación con algunas cosas y TypeScript, en relación con otras. Una comparación profunda de los dos sistemas será muy larga, así que solo mire algunos ejemplos.

Nota: todos los enlaces al entorno limitado de TypeScript asumen parámetros "estrictos". Desafortunadamente, cuando comparte un enlace, estas opciones no se almacenan en la URL. Por lo tanto, deben configurarse manualmente después de abrir cualquier enlace al sandbox de este artículo.

invariant.js


La función invariant resultó ser muy común en nuestro código fuente. Solo para citar la documentación:

 var invariant = require('invariant'); invariant(someTruthyVal, 'This will not throw'); // No errors invariant(someFalseyVal, 'This will throw an error with this message'); // Error raised: Invariant Violation: This will throw an error with this message 

La idea es clara: una función simple que arroja un error en alguna condición. Veamos cómo implementarlo y usarlo en Flow:

 type Maybe<T> = T | void; function invariant(condition: boolean, message: string) { if (!condition) { throw new Error(message); } } function f(x: Maybe<number>, c: number) { if (c > 0) { invariant(x !== undefined, "When c is positive, x should never be undefined"); (x + 1); // works because x has been refined to "number" } } 

Ahora cargue el mismo fragmento en TypeScript . Como puede ver en el enlace, TypeScript da un error, ya que no puede entender que x garantice que x no permanecerá undefined después de la última línea. Este es realmente un problema bien conocido : TypeScript (por ahora) no sabe cómo hacer esta inferencia a través de una función. Sin embargo, esta es una plantilla muy común en nuestra base de código, por lo que tuve que reemplazar manualmente cada instancia invariante (más de 150 piezas) con otro código que inmediatamente produce un error:

 type Maybe<T> = T | void; function f(x: Maybe<number>, c: number) { if (c > 0) { if (x === undefined) { throw new Error("When c is positive, x should never be undefined"); } (x + 1); // works because x has been refined to "number" } } 

Realmente no se compara con invariant , pero no es un tema tan importante.

$ ExpectError vs @ ts-ignore


Flow tiene una función muy interesante, similar a @ts-ignore , excepto que arroja un error si la siguiente línea no es un error. Esto es muy útil para escribir “pruebas de tipo” que aseguran que la verificación de tipo (ya sea TypeScript o Flow) encuentre ciertos errores de tipo.

Desafortunadamente, TypeScript no tiene esa función, por lo que nuestras pruebas han perdido algo de valor. Espero implementar esta función en TypeScript .

Errores de tipo genérico e inferencia de tipo


A menudo, TypeScript permite un código más explícito que Flow, como en este ejemplo:

 type Leaf = { host: string; port: number; type: "LEAF"; }; type Aggregator = { host: string; port: number; type: "AGGREGATOR"; } type MemsqlNode = Leaf | Aggregator; function f(leaves: Array<Leaf>, aggregators: Array<Aggregator>): Array<MemsqlNode> { // The next line errors because you cannot concat aggregators to leaves. return leaves.concat(aggregators); } 

El flujo infiere el tipo leaves.concat (agregadores) como Array <Leaf | Aggregator> , que luego se puede Array<MemsqlNode> en Array<MemsqlNode> . Creo que este es un buen ejemplo en el que Flow puede ser un poco más inteligente y TypeScript necesita un poco de ayuda: en este caso podemos aplicar una aserción de tipo, pero esto es peligroso y debe hacerse con mucho cuidado.

Aunque no tengo evidencia formal, creo que Flow es muy superior a TypeScript en inferencia de tipos. Realmente espero que TypeScript alcance el nivel de flujo, ya que el lenguaje se está desarrollando muy activamente, y se han realizado muchas mejoras recientes en esta área. En muchos lugares de nuestro código, TypeScript tuvo que ayudar un poco a través de anotaciones o aserciones de tipo, aunque evitamos lo último tanto como sea posible). Consideremos un ejemplo más (tuvimos más de 200 errores de este tipo):

 type Player = { name: string; age: number; position: "STRIKER" | "GOALKEEPER", }; type F = () => Promise<Array<Player>>; const f1: F = () => { return Promise.all([ { name: "David Gomes", age: 23, position: "GOALKEEPER", }, { name: "Cristiano Ronaldo", age: 33, position: "STRIKER", } ]); }; 

TypeScript no le permitirá escribir esto porque no le permitirá declarar { name: "David Gomes", age: 23, type: "GOALKEEPER" } como un objeto de tipo Player (vea el sandbox para el error exacto). Este es otro caso en el que encuentro que TypeScript no es lo suficientemente inteligente (al menos en comparación con Flow, que comprende este código).

Hay varias opciones para arreglar esto:

  • Declare que "STRIKER" es "STRIKER" para que TypeScript comprenda que la cadena es una enumeración válida de tipo "STRIKER" | "GOALKEEPER" "STRIKER" | "GOALKEEPER" .
  • Declarar todos los objetos como Player .
  • O lo que considero la mejor solución: solo ayude a TypeScript sin usar ninguna declaración de tipo escribiendo Promise.all<Player>(...) .

Aquí hay otro ejemplo (TypeScript) donde Flow es nuevamente mejor en la inferencia de tipos :

 type Connection = { id: number }; declare function getConnection(): Connection; function resolveConnection() { return new Promise(resolve => { return resolve(getConnection()); }) } resolveConnection().then(conn => { // TypeScript errors in the next line because it does not understand // that conn is of type Connection. We have to manually annotate // resolveConnection as Promise<Connection>. (conn.id); }); 

Un ejemplo muy pequeño pero interesante: Flow considera que Array<T>.pop() tipo T , y TypeScript lo considera T | void T | void Un punto a favor de TypeScript, porque te obliga a verificar la existencia de un elemento (si la matriz está vacía, entonces Array.pop devuelve undefined ). Hay varios otros pequeños ejemplos como este donde TypeScript es superior a Flow.

Definiciones de TypeScript para dependencias de terceros


Por supuesto, al escribir cualquier aplicación de JavaScript, tendrá al menos algunas dependencias. Deben estar escritos, de lo contrario perderá la mayoría de las posibilidades de análisis de tipo estático (como se describe al principio del artículo).

Las bibliotecas de npm pueden venir con definiciones de tipo Flow o TypeScript, con o sin ambas. Muy a menudo, las bibliotecas (pequeñas) no se suministran con una u otra, por lo que debe escribir sus propias definiciones de tipo o pedirlas prestadas a la comunidad. Tanto Flow como TypeScript admiten repositorios de definición estándar para paquetes JavaScript de terceros: estos son de tipo flujo y DefinitelyTyped .

Debo decir que DefinitelyTyped nos gustó mucho más. Con flow-typed, tuve que usar la herramienta CLI para introducir definiciones de tipo para varias dependencias en el proyecto. DefinitelyTyped combina esta función con la herramienta npm CLI enviando @types/package-name al repositorio de paquetes npm. Esto es muy bueno y simplificó enormemente la entrada de definiciones de tipos para nuestras dependencias (broma, reacción, lodash, reacción-reducción, estas son solo algunas).

Además, la pasé muy bien llenando la base de datos DefinitelyTyped (no creo que las definiciones de tipo sean equivalentes al portar código de Flow a TypeScript). Ya he enviado algunas solicitudes de extracción , y no hubo problemas en ningún lado. Simplemente clone el repositorio, edite definiciones de tipo, agregue pruebas y envíe una solicitud de extracción. El bot DefinitelyTyped GitHub marca a los autores de las definiciones que editó. Si ninguno de ellos proporciona comentarios dentro de los 7 días, la solicitud de extracción se envía para su consideración al responsable del mantenimiento. Después de fusionarse con la rama principal, se envía una nueva versión del paquete de dependencia a npm. Por ejemplo, cuando actualicé por primera vez el paquete @ types / redux-form, la versión 7.4.14 se envió automáticamente a npm. así que simplemente actualice el archivo package.json para obtener nuevas definiciones de tipo. Si no puede esperar la adopción de la solicitud de extracción, siempre puede cambiar las definiciones de los tipos que se utilizan en su proyecto, como se describe en uno de los artículos anteriores .

En general, la calidad de las definiciones de tipo en DefinitelyTyped es mucho mejor debido a la comunidad TypeScript más grande y próspera. De hecho, después de que el proyecto se transfirió a TypeScript , nuestra cobertura de tipos aumentó de 88% a 96% , principalmente debido a mejores definiciones de tipos de dependencia de terceros, con menos tipos.

Linting y pruebas


  1. Cambiamos de eslint a tslint (con eslint para TypeScript, parecía más difícil comenzar).
  2. Las pruebas de TypeScript usan ts-jest . Algunas de las pruebas se escriben, mientras que otras no (si se escriben durante demasiado tiempo, las guardamos como archivos .js).

¿Qué pasó después de corregir todos los errores de escritura?


Después de 40 horas-hombre de trabajo, llegamos al último error de escritura, posponiéndolo por un tiempo usando @ts-ignore .

Después de revisar los comentarios de revisión de código y corregir un par de errores (desafortunadamente, tuve que cambiar un poco el código de tiempo de ejecución para corregir la lógica que TypeScript no podía entender) la solicitud de extracción desapareció, y desde entonces hemos estado usando TypeScript. (Y sí, arreglamos el último @ts-ignore en la próxima solicitud de extracción).

Además de la integración con el editor, trabajar con TypeScript es muy similar a trabajar con Flow. El rendimiento del servidor de flujo es ligeramente mayor, pero este no es un gran problema, ya que generan errores para el archivo actual con la misma rapidez. La única diferencia de rendimiento es que TypeScript informa un nuevo error después de guardar el archivo un poco más tarde (por 0.5-1 s). El tiempo de inicio del servidor es aproximadamente el mismo (aproximadamente 2 minutos), pero no es tan importante. Hasta ahora, no hemos tenido ningún problema con el consumo de memoria. Parece que tsc usa constantemente alrededor de 600 MB.

Puede parecer que la función de inferencia de tipo le da a Flow una gran ventaja, pero hay dos razones por las que realmente no importa:

  1. Convertimos la base del código de flujo a TypeScript. Obviamente, encontramos solo ese código que Flow puede expresar, pero TypeScript no. Si la migración hubiera ocurrido en la dirección opuesta, estoy seguro de que habría cosas que TypeScript mejor muestra / expresa.
  2. La inferencia de tipos es importante para ayudar a escribir código más conciso. Pero de todos modos, otras cosas son más importantes, como una comunidad fuerte y la disponibilidad de definiciones de tipo, porque la inferencia de tipo débil se puede solucionar al pasar un poco más de tiempo escribiendo.

Estadísticas de código


 $ npm run type-coverage # https://github.com/plantain-00/type-coverage 43330 / 45047 96.19% $ cloc # ignoring tests and dependencies -------------------------------------------------------------------------------- Language files blank comment code -------------------------------------------------------------------------------- TypeScript 330 5179 1405 31463 

Que sigue


No hemos terminado de mejorar el análisis de tipo estático. MemSQL tiene otros proyectos que eventualmente cambiarán de Flow a TypeScript (y algunos proyectos de JavaScript que comenzarán a usar TypeScript), y queremos que nuestra configuración de TypeScript sea más rigurosa. Actualmente tenemos habilitada la opción strictlyNullChecks , pero noImplicitAny todavía está deshabilitado. También eliminaremos un par de declaraciones de tipo peligroso del código.

Me alegra compartir contigo todo lo que aprendí durante mis aventuras escribiendo JavaScript. Si está interesado en un tema específico, hágamelo saber .

Source: https://habr.com/ru/post/es436554/


All Articles