Tuve la necesidad de convertir un arreglo de objetos (Array<{ id: string, name: string }>
) en un sólo objeto donde la llave era el campo id
y el valor era el campo name
. Esto puede parecer muy sencillo al inicio, y lo es, pero a la hora de tipar correctamente el resultado en TypeScript me demoré un buen tiempo investigando hasta lograr encontrar la respuesta.
Advertencia: Usaré el anglicismo "tipar" y "tipado" para referirme a la acción de crear los tipos de variables en TypeScript. Esta palabra no existe en Español.
Función sin tipar
Queremos crear una función que haga la siguiente conversión:
arrayCollectionToObject([
{ id: 'A', name: 'First' },
{ id: 'B', name: 'Second' },
{ id: 'C', name: 'Third' }
]); // { A: 'First', B: 'Second', C: 'Third' }
Comencemos con escribir la función que realizaría esta acción sin hacer uso alguno de tipos. La función se vería más o menos así:
function arrayCollectionToObject(collection) {
const result = {};
for (const item of collection) {
result[item.id] = item.name;
}
return result;
}
Nota: Para disminuir un poco la complejidad del código en el ejemplo he usado una aproximación imperativa en lugar de usar una aproximación declarativa haciendo uso del reduce.
Vamos a describir que hace la función línea a línea.
const result = {};
En esta línea simplemente estamos creando un nuevo objeto, este será el objeto en el cual realizaremos las operaciones de conversión del array.
for (const item of collection) {
result[item.id] = item.name;
}
Aquí estamos iterando uno a uno los elementos que están en el array, haciendo uso de la sentencia for...of, y dentro del bloque for
estamos agregando al objeto result
una nueva llave que tendrá como valor lo que tenga item.id
y que tiene como valor lo que tenga item.name
.
return result;
Aquí devolvemos nuestro objeto result
después de que le agregamos las llaves y valores necesarios.
Problema
Nuestro código funciona correctamente. Si le enviamos un arreglo de objetos con la estructura esperada obtendremos como resultado un sólo objeto.
arrayCollectionToObject([
{ id: 'A', name: 'First' },
{ id: 'B', name: 'Second' },
{ id: 'C', name: 'Third' }
]); // { A: 'First', B: 'Second', C: 'Third' }
Pero hay un problema en el tipado con TypeScript, el parámetro acepta cualquier tipo variable (any
) y el tipo de objeto retornado es simplemente un objeto vacío ({}
).
Si a nuestra función le pasamos un argumento cualquiera, este será aceptado, TypeScript no validará nada y podremos tener errores en tiempo de ejecución.
arrayCollectionToObject(42); // TypeError. Error en tiempo de ejecución 😭
Si usamos un editor con auto-completado (como Visual Studio Code) no podremos aprovechar el auto-completado en el objeto devuelto por la función.
Mejorando el tipado de nuestra función
Tenemos como objetivo asegurar el tipo de dato que recibirá la función, permitiendo sólo colecciones de objetos que cumplan con la estructura esperada, y también debemos mejorar el tipado del objeto que devuelve la función.
Asegurando el parámetro
Para asegurar el parámetro vamos a hacer uso de Generics. Los Generics son una utilidad que permiten generalizar los tipos, permiten capturar el tipo provisto por el usuario para poder utilizar esta información del tipo en un futuro.
function arrayCollectionToObject<
T extends { id: S; name: string },
S extends string
>(collection: T[] = []) {
// Resto del código...
}
En este pequeño cambio estamos realizando lo siguiente:
T extends { id: S; name: string }
Estamos diciendo que vamos a recibir un valor con un tipo determinado de dato y a este tipo lo llamaremos T
. Lo único de lo que estamos seguros es que el tipo de dato que recibimos es un objeto y que tiene por lo menos las propiedades id
y name
.
La propiedad id
tendrá otro Generic, a este tipo determinado de dato lo llamaremos S
y nos servirá más adelante para poder agregar correctamente el tipo del resultado.
S extends string
Aquí estamos agregando otra restricción a nuestro Generic llamado S
. Estamos asegurándonos que el valor que tendrá este tipo será un sub-tipo de string
.
Con este pequeño cambio ya estamos seguros de que nuestra función sólo recibirá como argumento un valor que cumpla con la estructura que esperamos. En caso de no cumplir con la estructura esperada, obtendremos un error en tiempo de compilación.
arrayCollectionToObject(42); // Error en tiempo de compilación 🥳
Asegurando el objeto resultante
En el paso anterior logramos asegurar el tipo del parámetro que se recibirá en la función y prevenir que se le pase como argumento cualquier tipo de valor. También podemos hacer que nuestra función nos provea un tipo más específico en el resultado que se obtiene al ejecutarla.
El objetivo es que el tipo del objeto resultante tenga como nombre de las llaves el valor que tenía cada elemento del arreglo en la llave id
. Para lograr esto, sólo debemos hacer un cambio en la siguiente linea:
function arrayCollectionToObject<...>(collection: T[] = []) {
const result = {} as { [K in T['id']]: string };
// Resto del código...
}
Esta linea lo que hace es que un tipo de objeto cuyas llaves serán iguales a cada uno de los valores de id
existentes en T
y su valor será un string
.
¿Recuerdas que había un Generic llamado S
en la declaración de la función? Resulta que el Generic es usado para poder tener un String literal, si no hubiésemos hecho esto, TypeScript nos hubiera tipado las llaves del objeto resultante como un string
y no con el valor exacto de cada id
.
De esta forma, ya podemos ver que el auto-completado de nuestro editor funciona correctamente.
Código final
Después de agregar los tipos nuestro código debería haber quedado de la siguiente forma:
function arrayCollectionToObject<
T extends { id: S, name: string },
S extends string
>(collection: T[] = []) {
const result = {} as { [K in T['id']]: string };
for (const item of collection) {
result[item.id] = item.name;
}
return result;
}
Conclusión
No soy un experto en TypeScript y mi experiencia con el lenguaje es poca, pero lo poco que he conocido me ha demostrado que se pueden realizar cosas muy interesantes con su sistema de tipos. Realizar este pequeño ejemplo me ayudó a fortalecer bases sobre Generics, restricción de Generics, protección de tipos y mapeado de tipos.
Es cierto que encontrar los tipos correctos en nuestro código a veces puede llevarnos muchísimo tiempo, encontrarlos para este ejercicio me llevó más tiempo del que hubiera deseado, pero se debe ver esto como una inversión a futuro. El tener nuestro código con un tipado correcto nos podrá asegurar muchísimas cosas a medida que el proyecto crece.
Créditos a Mohammad Rahmani por la foto de portada del artículo.
Top comments (1)
👏👏👏