DevJoseManuel
DevJoseManuel

DevJoseManuel

TypeScript Challenge - #01 - Pick

TypeScript Challenge - #01 - Pick

DevJoseManuel's photo
DevJoseManuel
·Jul 30, 2022·

7 min read


En este desafío lo que perseguiremos es construir el tipo Pick<T, K> sin hacer uso del mismo. Deberemos construir un tipo de datos que nos permitirá extraer el conjunto de propiedades K de un tipo T. Por ejemplo:

interface Todo {
    title: string
    description: string
    completed: boolean
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>

const todo: TodoPreview = {
    title: 'Clean room',
    completed: false
}

Solución

MyPick<T, K extends keyof T> = {
    [P in K]: T[P]
}

Explicación

Vamos a partir de la definición básica del tipo de datos que queremos construir donde estableceremos los parámetros que esperamos que reciba y supondremos además que inicialmente retornará any:

type MyPick<T, K> = any

Pero antes de continuar vamos a detenernos unos instantes para entender bien cómo ha de funcionar MyPick. Para ello nos vamos a apoyar en la declaración de la interfaz Todo de la definición del challenge presentando tres escenarios:

MyPick<Todo, 'title'>
MyPick<Todo, 'title' | 'completed'>
MyPick<Todo, 'title' | 'completed' | 'invalid'>

¿Cómo ha de comportarse MyPick en cada una de los tres escenarios anteriores?

  • En el primero de los casos MyPick no debería dar un error puesto que title es uno de los atributos que están recogidos dentro de la interfaz Todo.
  • En el segundo caso tampoco deberíamos tener un error puesto que el union type que está formado por title y completed se corresponde con dos de los atributos que están definidos en MyPick.
  • El tercer caso es diferente puesto que estamos nuevamente frente a un union type pero en este caso la particularidad es que uno de los valores que lo forman no se corresponde con uno de los atributos de la interfaz Todo por lo que MyPick debería informarnos de ello con un mensaje de error.

Con esto en mente parece que la clave para poder resolver nuestro problema está en restringir de alguna manera el conjunto de los valores que podemos escribir en el segundo de los parámetros de MyPick<T, K> o, dicho de otra manera, definir K de tal manera que solamente pueda aceptar valores que se corresponden con los atributos de T.

keyof

Ahora bien ¿cómo podemos decir en TypeScript que queremos extraer el conjunto de los atributos que están asociados con un tipo? La respuesta es gracias al uso del operador keyof.

Para entenderlo mejor con nuestro ejemplo, si aplicamos este operador a la interfaz Todo lo que vamos a obtener es un union type formado por el nombre de todos los atributos que están definidos dentro de la interfaz.

keyof Todo ---> 'title' | 'description' | 'completed'

extends

Ahora que ya tenemos el tipo de datos formado por todos los atributos de la interfaz tenemos que restringir de alguna manera que solamente se puedan elegir entre uno de ellos y aquí es donde entran en juego los conditional types.

Pensando en términos de teoría de conjuntos:

  • ¿Qué es lo que queremos probar? Que el conjunto formado por los tipos de datos que está asociados a todos los atributos que queremos extraer es un subconjunto del conjunto formado por los atributos de tipo con el que estamos trabajando.
  • ¿TypeScript nos ofrece una mecanismo para poder realizar esta comprobación? Sí, y es el uso de los conditional types o, en nuestro caso, del operador extends.

Como podemos intuir extends es un operador que nos va a permitir realizar comprobaciones de forma similar a como las realizamos JavaScript:

A extends B ---> true siempre que A sea un subconjunto de los elementos de B.
A extends B ---> false en caso contrario.

Plasmemos esta idea en los escenarios que hemos mostrado anteriormente:

B = keyof Todo ---> 'title' | 'description' | 'completed'

'title' extends B --> true 
'title' | 'completed' extends B --> true
'title' | 'completed' | 'invalid' extends B --> false

Uniendo todas las piezas

Ya solamente nos quedan unir las dos piezas anteriores para lograr nuestro objetivo estableciendo que el conjunto de los valores que se pueden establecer en el parámetro K ha de pertenecer al conjunto de los atributos que está recogidos en el tipo T. Esto nos deja con lo siguiente:

MyPick<T, K extends keyof T> = any

Retornando el valor

En la declaración anterior estamos viendo que el resultado que se está devolviendo es any y esto no es lo que se persigue en el desafío puesto que lo que queremos es retornar un objeto que contenga únicamente los atributos de T que hemos especificado en el parámetro K.

La forma de lograrlo es utilizando lo que se conoce como un Mapped Type que no es más que construcción de un nuevo tipo a partir de la definición de otro.

Vamos a comenzar definiendo el mapped type más sencillo que se nos puede ocurrir para lograr nuestro objetivo que sería aquel que siempre devolviese un tipo sin atributos como resultado:

MyPick<T, K extends keyof T> = {}

¿Cómo definimos ahora los atributos de este objeto? Pues aquí es donde tenemos que entender que tanto T como K son dos tipos que estarán disponibles durante la declaración del tipo que estamos construyendo (en cierta medida, si pensamos en términos de JavaScript, es como si ambos tipos estuviesen vinculados al scope de la declaración de nuestro nuevo tipo) y que, además, para la definición de los atributos nos interesará el conjunto que está recogido en K.

¿Existe una posibilidad de recorrer todos los valores recogidos en K en TypeScript? La respuesta es que sí y es gracias al uso del operador in. Pensando en términos de arrays podíamos hacernos un esquema mental como el siguiente:

K ---> 'title' | 'description' | 'completed'
P in K ---> ['title', 'description', 'completed']

donde P es un tipo que pasará a tener los valores title, description y completed respectivamente mientras se está recorriendo el array.

¿Y podemos definir un atributo dentro del mapped type a partir de cada uno de estos elementos del array? La respuesta nuevamente es que sí utilizando para ello la notación entre [] donde

MyPick<T, K extends keyof T> = {
    [P in K]: ...
}

Esto que de palabra es complejo de explicar se ve mejor en el ejemplo que estamos siguiendo:

interface Todo {
    title: string
    description: string
    completed: boolean
}

MyPick<Todo, 'title'> ---> { title: ... }
MyPick<Todo, 'title' | 'description'> ---> { title: ..., description: ... }
MyPick<Todo, 'title' | 'description' | 'completed'> ---> { title: ..., description: ..., completed: ... }

Ya solamente nos queda por asignar el tipo de datos que está asociado a cada uno de los atributos que estamos extrayendo y esto se consigue de forma muy sencilla sin más que utilizar el operador [] sobre el tipo de datos indicando cuál es el elemento del que queremos extraer la información del tipo (de forma análoga a cómo podemos hacerlo en TypeScript para acceder a cada uno de los elementos de un objeto).

Pero ¿cómo sabemos cuál es el atributo concreto? Pues así tenemos que volver a recordar que todos los tipos genéricos que vayamos utilizando durante la declaración de nuestro tipo pueden ser utilizados en el resto de su definición.

En nuestro caso, como hemos declarado algo como [P in K] para recorrer todos los atributos que están recogidos dentro del conjunto de las claves a extraer K y como además tenemos la certeza de que estas claves pertenecerán al tipo sobre el que estamos trabajando T ya simplemente podemos utilizar T[P] para obtener el tipo asociado a dicho atributo:

MyPick<T, K extends keyof T> = {
    [P in K]: T[P]
}

Si volvemos a nuestro ejemplo obtendríamos algo como lo siguiente:

interface Todo {
    title: string
    description: string
    completed: boolean
}

MyPick<Todo, 'title'> 
    ---> { title: string }
MyPick<Todo, 'title' | 'description'> 
    ---> { title: string, description: string }
MyPick<Todo, 'title' | 'description' | 'completed'> 
    ---> { title: string, description: string, completed: boolean }

Enlaces de interés

 
Share this