Bueno nada es realmente gratis, pero en este caso la ratio lo que obtienes / lo que pagas por ello es bastante bueno. Pero déjame primero hacer una introducción.
Typescript es un lenguaje de programación planteado como una extensión de Javascript que añade tipos y programación orientada a objetos basada en clases. Además, es de código abierto y actualmente es mantenido por Microsoft.
Este lenguaje ha ido ganando popularidad principalmente por ayudar en la fase de generación de código, ya que tener algo que te da feedback sobre lo que estás haciendo no solo te ahorra errores tontos, sino que si estas aprendiendo, te hace el camino mucho más seguro.
Pero no todo es bueno, ya que a medida que el lenguaje iba creciendo en popularidad también se iba materializando un miedo que incomodaba a cierta para de la comunidad — en la que me incluyo —. Era el hecho de convertir de alguna forma a Javascript en Java trayendo de nuevo uno de los problemas más criticados de los lenguajes fuertemente tipados: las abstracciones complejas.
Sin embargo, este miedo era infundado ya que el objetivo de la comunidad de Typescript no es crear un nuevo Java para el navegador, sino mimetizarse con Javascript. O lo que es lo mismo, su objetivo es definir algo que nunca se terminó de materializar dentro del ecosistema de Javascript: Un sistema de tipos.
Así por ejemplo encontramos proyectos tan chulos como @types cuyo objetivo es proporcionar tipos estáticos para todos los paquetes de NPM que usamos diariamente.
Características de Typescript
Dentro de todo lo que ofrece este lenguaje, los que creo que más han contribuido a su popularización son los siguientes:
Fuerte sistema de tipos. Aunque no es tan poderoso en mi opinión como otros lenguajes (Rust, Python) es de lo mejor que le ha pasado a Javascript en este último tiempo.
Feedbacks de compilación. Mucho mejores que los de Java pero a años luz todavía de otras soluciones (Elm Lang, Rust, etc)
Auto documentación de tipos. Ya que los tipos en sí mismos son una forma de documentar. Que tu IDE te de información sobre el tipo de parámetros que recibe una determinada función es una ayuda valiosísima no solo para juniors o nuevas incorporaciones a un proyecto sino para aquellos más experimentados.
Evitar errores tontos. Ojo, que no los bugs. Por eso es importante siempre tener una buena batería de tests.
Por el contrario, existe una contra que puede poner (y pone) en jaque a todas las demás características positivas: La transpilación.
Typescript se ha de compilar a Javascript para poder ser ejecutado en el navegador o en cualquier otro runtime, ya que estos por defecto no entienden Typescript.
En este punto nos podemos poner como queramos, pero la transpilación de algo nunca va a ser mejor que la NO transpilación.
Y algunos aquí podrían argumentarme que muchos proyectos de Javascript son aún muy dependientes de herramientas como BabelJS para asegurar la retrocompatibilidad sin renunciar a las últimas features de la especificación. Y tienen razón, en parte, pero no se puede obviar un factor esencial: la idea de usar tecnologías del tipo de BabelJS es que eventualmente y a medida que los navegadores viejos van siendo deprecados y los nuevos implementando las últimas novedades, dejemos de depender de esas herramientas.
Lo mejor de dos mundos
Llegados a este punto toca hablar del tema del artículo de hoy, ya que existe una forma de obtener lo bueno de un sistema de tipos sin tener que transpilar. Esa forma se llama: JSDoc.
Si no has dejado de leer aun sintiéndote estafado por lo que acabo de decir, te explico.
JSDoc es una evolución de JavaDoc que fue inventado para escribir y generar documentación de código.
La primera vez que oí que se podía utilizar JSDoc para definir tipos de TS que fuesen entendidos por el IDE para obtener feedbacks fue en el podcast Javascrpt Jabber (en inglés).
Nosotros no vamos a documentar el código, — aunque es algo que siempre es bueno hacer — solo vamos a definir los tipos de las variables y funciones que usemos de una forma que pueda ser entendida por Typescript.
En JSDoc para documentar parámetros simplemente dentro de un bloque multilínea de comentarios tenemos que agregar una línea por parámetro que tenga esta forma:
@param {<ts-type>} <paramName>
Donde el tipo debe de ser un tipo de Typescript.
Ahora y como Visual Studio Code soporta Typescript de forma nativa, si hacemos “hover” sobre la función add, obtendremos esta maravilla:
No solo ha leído los dos tipos que hemos definido, sino que además ¡ha inducido el valor de retorno!, ya que la suma de dos números devuelve otro número.
Esto no ocurre solo con VSCode sino que la gran mayoría de los IDEs actualmente ya ofrecen estos soportes, ya sea de forma nativa o mediante algún plugin.
Agregando control de tipos
Bien, pero ¿qué ocurre si una vez definidos esos tipos para la función add, la llamamos con argumentos que impliquen una violación de tipos?
En este caso el editor no nos dice nada. Sin embargo, si añadimos //@ts-check
en la primera línea del fichero:
Obtenemos información sobre violación de tipos for free.
Inducción de tipos
En lenguajes con tipos fuertes y en donde estos se han de especificar de forma estática, el compilador puede intentar adivinar el tipo de una variable o parámetro si no se lo hemos proporcionado. Esto hace el código más versátil y reduce un poco una de las desventajas mayores de este tipo de lenguajes, que es el pararse demasiado tiempo en la abstracción en lugar de en el dataflow del programa.
Con Typescript tenemos el mismo comportamiento, si un tipo no está explícitamente definido, el compilador puede intentar inducirlo. Veamos este ejemplo:
No tenemos ningún error y si hacemos “hover” sobre la variable “res” que acabamos de crear para guardar el resultado de aplicar la función add a dos números, obtenemos lo siguiente:
Esto es porque Typescript indujo el tipo de retorno de la función add basado en los tipos de entrada y por tanto pudo obviar cual será el tipo de la variable “res”. No obstante, nosotros podemos prescindir de esta inducción y proporcionarle el tipo en JSDoc con @type {<ts-type>}
:
Recuerda que toda la información de JSDoc se ha de proporcionar siempre mediante un comentario multilínea, no valiendo, por tanto, un comentario normal.
Definiendo nuevos tipos
Veamos ahora como podemos definir nuestros propios tipos con JSDoc de igual forma que lo haríamos en Typescript. Empecemos definiendo una función que dado un nombre y un apellido nos devuelva a un nuevo usuario:
El problema es que si ahora queremos definir una función que trabaje sobre algo que se parezca a un User tenemos que hacer:
Un poco tedioso. Lo suyo sería definir el tipo User y poder usarlo de forma acorde en cada función que trabaje con usuarios. Para ello vamos a usar la propiedad de JSDoc @typedef
al principio del fichero:
/** @typedef {{ name: string, lastname: string }} User */
Y ahora ya podemos utilizarlo en nuestro código:
Configurando el compilador de Typescript
Ahora que ya hemos visto un poco lo potente que es definir tipos en nuestro código con sintaxis de JSDoc, vamos a configurar un poco el compilador mediante un fichero de configuración — que es leído automáticamente por VSCode — y de paso, definir una etapa de control de tipos en nuestro proceso de empaquetado. Todo ello sin obviamente transpilar nada.
Creamos por tanto un fichero llamado tsconfig.json en la raíz de nuestro proyecto y le agregamos la siguiente información:
{
"compilerOptions": {
"lib": ["es2020", "DOM"],
"moduleResolution": "node",
"module": "esnext",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict":true
},
"include": ["src/**/*.js"],
"exclude": ["node_modules"]
}
Por partes:
Lib: las librerías que definen el “target” de compilación. En nuestro caso estamos usando el superconjunto de ES2020 y la librería de DOM (para que reconozca cosas como
console.log
.ModuleResolution, Module: El tipo de módulo en el que vamos a exportar el código y como vamos a resolver. En mi caso exportamos a módulo esnext y resuelvo con node porque estoy haciendo los ejemplos en Nodejs.
AllowJs y CheckJs: Para que el sistema de control de tipos funcione en ficheros de Javascript, ya que por defecto solo acepta ficheros Typescript.
noEmit: Este es el más importante, ya que le dice al compilador que no transpile el código.
Finalmente incluimos las rutas donde vamos a aplicar la configuración y excluimos los node_modules
.
La configuración de rutas aquí se hace usando sintaxis fast-glob.
Automágicamente VSCode leerá este nuevo fichero y aplicará la configuración sin necesidad de hacer nada.
Si queremos ahora crear un script para aplicar control de tipos sobre nuestro código, necesitamos instalar Typescript y el compilador:
npm i -D typescript tsc
Y ejecutando el comando tsc a través de un npm script o con npx, obtendremos los errores por la terminal:
Si ahora añadimos este proceso a nuestro pipeline de integración podremos decidir si construimos o no en base a que el proceso de validación de tipos vaya o no correctamente.
Trabajando con genéricos
Los genéricos son una forma de escribir software en la que la información sobre los tipos se proporciona después. De tal manera que podemos definir un algoritmo que trabaje sobre un supuesto tipo T y posteriormente indicar cual es. Además, podemos aplicar técnicas como narrowing types en las que usando algo similar a guards, podamos decir que T ha de pertenecer a un subconjunto acotado de tipos y así evitar el uso del tipo “any” que siempre es poco recomendado.
JSDoc permite especificar genéricos en los tipos si previamente hemos definido el template. La sintaxis es un poco más verbosa que usando Typescript directamente, pero no por ello menos potente.
Para el ejemplo vamos a implementar una versión simplificada de un Maybe parecido al que vimos en este artículo.
Empecemos definiendo los dos tipos de los que se componente un Maybe: Just
y Nothing
.
Como el sistema de type unions de Typescript no es tan potente como otros lenguajes y no podemos definir nuevos tipos a la misma vez que hacemos el union, tenemos que definir previamente los tipos Just
y Nothing
y después unirlos bajo el tipo Maybe.
Un Maybe es una estructura que guarda en su interior un
Just<T>
en el caso de que haya valor o unNothing
en el caso contrario. T aquí representa cualquier tipo.
Empezamos definiendo el tipo Just<T>
:
const id = Symbol();
/**
* @template T
*/
class Just {
/**
*
* @param {T} value
*/
constructor(value) {
this[id] = value;
}
}
Definimos el template para el tipo T y definimos el tipo Just<T>
con una clase que guardará el valor. ¿Cuál es el tipo de ese valor? Pues T, que corresponde al template que acabamos de definir.
Así decir por ejemplo Just<number>
equivaldrá a guardar un valor de tipo ‘number’.
Hacemos lo propio con Nothing
, pero esta vez como este tipo no guarda valor, no tenemos que definir ningún template:
const id = Symbol();
class Nothing {
constructor(value) {
this[id] = value;
}
}
Nótese que estoy usando un Symbol
para mantener el valor inaccesible desde el exterior.
Aquí hemos tenido que definir de forma individual los tipos
Just
yNothing
.
En sistemas de tipos más potentes se puede hacer todo de una. Por ejemplo, así definiríamos un tipo Maybe en Elm lang:
type Maybe a = Just a | Nothing
Y ambos Just y Nothing actuarían ya como “constructores” del tipo Maybe.
Finalmente, definimos el Maybe<T>
como una unión de los dos tipos anteriormente creados:
/**
* @template T
* @typedef { Just<T> | Nothing } Maybe<T>
*/
Si quieres más información sobre los type union, te recomiendo la docu oficial de TS.
Cabe destacar que podríamos también acotar los tipos con la sintaxis:
@template {<ts-type>|<ts-type>) T
Más información: https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#template
Ahora vamos a proporcionarle la funcionalidad de unwrapOr para poder obtener el valor del Maybe en caso de que lo haya y si no un valor por defecto que deberemos de proveer en el momento de llamar a la función. Empezamos definiendo el unwrapOr para un Just<T>
.
/**
* @template T
*
*/
class Just {
/**
*
* @param {T} value
*/
constructor(value) {
this[id] = value;
}
/**
* @template K
* @param {K} defaultValue
*
* @returns {T | K}
*/
unwrapOr(defaultValue) {
return this[id];
}
}
El template T está definido en la clase y representa el tipo del valor que habrá dentro del Maybe, en cambio para la función unwrapOr adicionalmente me he definido otro template K para hacer referencia al tipo del valor por defecto — ya que no tiene por qué ser del mismo tipo que T —.
El tipo de retorno es un union type entre T y K porque si hay algo dentro del Maybe será del tipo T y sino será el valor por defecto con tipo K.
Importante: He estado usando T/K porque es un poco convencional, pero realmente se puede poner cualquier nombre. Lo normal es usar T,K,J,I, etc. O X,Y, etc.
Llegado este momento podemos usarlo:
const myBox = new Just(1);
const res = myBox.unwrapOr(2); // 1
const myBox2 = new Nothing();
const res2 = myBox2.unwrapOr('hello') // hello
Importando tipos
Lo normal sería tener nuestro módulo Maybe en un fichero a parte y después importar los diferentes constructores para usarlo en cualquier parte de nuestra aplicación. Esto mismo podemos hacerlo con los tipos.
Para importar un tipo de un módulo que defina tipos — con @typedef
— podemos usar una variante de @typedef
:
@typedef {import('path/to/file').<Type>} <TypeName>
Así y suponiendo que nuestro Maybe y sus tipos estén definidos en un fichero Maybe.js, podríamos hacer esto desde otro fichero:
/**
* @template T
* @typedef {import('./Maybe2').Maybe<T>} Maybe
*/
Importante definir el template si estamos importando un tipo con genérico. Y más importante aún realizar esta declaración antes de usarlo.
Conclusión
Como puedes ver usas JSDoc para traer tipos a Javascript es una forma un tanto peculiar pero bastante interesante cuando queremos aprovecharnos de las ventajas de un sistema de tipos potente sin tener que pagar el tradeoff de aumentar el tiempo de compilado.
Hemos visto como definir tipos sobre variables y funciones y hasta el trabajo con genéricos que es algo bastante avanzado de los lenguajes fuertemente tipados. No todo lo que podemos hacer con Typescript es posible a través de JSDoc pero por citar algunas de las cosas que se han quedado en el tintero:
Casting de tipos.
Implementación de tipos en clases (con @implements).
Uso de ficheros .d.ts.
Extensión de genéricos.
Overrides.
Sin embargo, hemos obviado el hecho de que el código se vuelve un poco más ruidoso y que perdemos la flexibilidad que nos da Javascript de centrarnos en el problema de negocio en lugar de perdernos en complejas fases de diseño y abstracción.
Creo que la desventaja se contrarresta bastante bien con la forma en la que traemos los tipos a Javascript sin convertirlo a nada más por el camino. Por consiguiente, si mañana creemos que los tipos no son necesarios o nos están ralentizando demasiado, podemos eliminar el fichero tsconfig.json y todo seguirá funcionando igual.
Además, podemos decidir que partes del programa usan tipos y cuáles no. Quizá en el core de nuestra aplicación tenga mucho sentido definir todo de forma estática, pero en otros lugares no.
Por último, es importante recalcar que un sistema de tipado estático no reemplaza a los tests. Quizá tener un feedback de tipos puede ser útil para acelerar la fase de desarrollo e incluso nos evite errores tontos, pero no puede asegurar que la lógica que estemos escribiendo sea correcta y cumpla las reglas de negocio.
Usa este nuevo poder con responsabilidad.
Nada más por hoy :)
Si te gustó mi artículo en El rincón del front déjame un like, si te encantó compártelo y si te flipó suscríbete para no perderte el siguiente! :)