TypeScript avanzado con un ejemplo real (Parte 2)
Publicado: 2020-11-20Es hora de continuar (y con suerte terminar) nuestro tutorial de TypeScript. Si te has perdido las publicaciones anteriores que hemos escrito sobre TypeScript, aquí están: nuestra introducción inicial a TypeScript y la primera parte de este tutorial donde explico el ejemplo de JavaScript con el que estamos trabajando y los pasos que seguimos para mejorarlo parcialmente. .
Hoy vamos a terminar nuestro ejemplo completando todo lo que falta. Específicamente, primero veremos cómo crear tipos que sean versiones parciales de otros tipos existentes. Luego veremos cómo escribir correctamente las acciones de una tienda Redux usando uniones de tipos, y discutiremos las ventajas que ofrecen las uniones de tipos. Y, finalmente, te mostraré cómo crear una función polimórfica cuyo tipo de retorno depende de sus argumentos.
Una breve reseña de lo que hemos hecho hasta ahora...
En la primera parte del tutorial usamos (parte de) una tienda Redux que tomamos de Nelio Content como nuestro ejemplo de trabajo. Todo comenzó como un código JavaScript simple que tuvo que mejorarse agregando tipos concretos que lo hicieran más robusto e inteligible. Así, por ejemplo, definimos los siguientes tipos:
type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; }; lo que nos ayudó a entender, de un vistazo, el tipo de información con la que trabaja nuestra tienda. En este caso particular, por ejemplo, podemos ver que el estado de nuestra aplicación almacena dos cosas: una lista de posts (que hemos indexado a través de su PostId ) y una estructura llamada days que, dado un día determinado, devuelve una lista de identificadores de publicaciones. También podemos ver los atributos (y sus tipos específicos) que encontraremos en un objeto Post .
Una vez definidos estos tipos, editamos todas las funciones de nuestro ejemplo para usarlos. Esta simple tarea transformó las firmas de funciones opacas de JavaScript:
// Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }a las firmas de funciones de TypeScript que se explican por sí mismas:
// Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... } La función getPostsInDay es un muy buen ejemplo de cuánto mejorará TypeScript la calidad de su código. Si observa la contraparte de JavaScript, realmente no sabe qué devolverá esa función. Claro, su nombre podría insinuar el tipo de resultado (¿es una lista de publicaciones, tal vez?), pero debe mirar el código fuente de la función (y probablemente también las acciones y los reductores) para estar seguro (en realidad es una lista de ID de publicación). Se puede mejorar esta situación nombrando mejor las cosas ( getIdsOfPostsInDay , por ejemplo), pero no hay nada como tipos concretos para despejar cualquier duda: PostId[] .
Entonces, ahora que está al día con el estado actual de las cosas, es hora de seguir adelante y arreglar todo lo que nos saltamos la semana pasada. Específicamente, sabemos que necesitamos escribir los attributes de la función updatePost y necesitamos definir los tipos que tendrán nuestras acciones (tenga en cuenta que en reducer , el atributo de action este momento es de tipo any ).
Cómo escribir un objeto cuyos atributos son un subconjunto de los de otro objeto
Entremos en calor comenzando con algo simple. La función updatePost genera una acción que señala nuestra intención de actualizar ciertos atributos de una ID de publicación dada. Así es como se ve:
function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }y así es como el reductor utiliza la acción para actualizar la publicación en nuestra tienda:
function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }Como puedes ver, el reductor busca la publicación en la tienda y, si está allí, actualiza sus atributos sobrescribiéndolos con los incluidos en la acción.
Pero, ¿qué son exactamente los attributes de una acción? Bueno, claramente son algo similar a una Post , ya que se supone que sobrescriben los atributos que podemos encontrar en una publicación:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };pero si intentamos usar esto veremos que no funciona:
const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; porque no queremos que los attributes sean una Post en sí; queremos que sea un subconjunto de atributos de Post (es decir, queremos especificar solo aquellos atributos de un objeto de Post que sobrescribiremos).
Para resolver este problema, simplemente use el tipo de utilidad Partial :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };¡Y eso es! ¿O es eso?
Filtrado de atributos de forma explícita
El fragmento anterior sigue siendo defectuoso, ya que es posible obtener algunos errores de tiempo de ejecución que el compilador de TypeScript no está comprobando. He aquí por qué: la acción que señala una actualización de publicación tiene dos argumentos, una ID de publicación y el conjunto de atributos que queremos actualizar. Una vez que tenemos la acción lista, el reductor se encarga de sobreescribir el post existente con los nuevos valores:
const post = { ...state.posts[ action.postId ], ...action.attributes, }; Y esa es precisamente la parte defectuosa de nuestro código; es posible que el atributo postId de la acción tenga un ID de macetas x y el atributo id en los attributes tenga un ID de publicación diferente y :
const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, }; Obviamente, esta es una acción válida, por lo que TypeScript no desencadena ningún error, pero sabemos que no debería ser así. El atributo id en attributes (si está presente) y el atributo postId deben tener el mismo valor, o de lo contrario tenemos una acción incoherente. Nuestro tipo de acción es impreciso porque nos permite definir una situación que debería ser imposible… entonces, ¿cómo podemos solucionar esto? Bastante fácil: simplemente cambie este tipo para que este escenario que debería ser imposible se vuelva realmente imposible.
La primera solución que pensé es la siguiente: elimine el atributo postId de la acción y agregue la ID en el attributes de atributos:
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; } Luego, actualice su reductor para que use action.attributes.id en lugar de action.postId para encontrar y sobrescribir la publicación existente.
Desafortunadamente, esta solución no es la ideal, porque los attributes son una "publicación parcial", ¿recuerdas? Esto significa que, en teoría, el atributo id puede o no estar en el objeto de attributes . Claro, sabemos que estará allí, porque somos nosotros los que generamos la acción... pero nuestros tipos aún son imprecisos. Si en el futuro alguien modifica la función updatePost y no se asegura de que los attributes incluyan el postId , la acción resultante sería válida según TypeScript pero nuestro código no funcionaría:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Entonces, si queremos que TypeScript nos proteja, debemos ser lo más precisos posible al especificar tipos y asegurarnos de que hacen imposibles los estados imposibles. Teniendo en cuenta todo esto, solo tenemos dos opciones disponibles:
- Si tenemos un atributo
postIden acción (como hicimos al principio), entonces el objeto deattributesno debe contener un atributoid. - Si, por otro lado, la acción no tiene un atributo
postId, entonces losattributesdeben contener un atributoid.
La primera solución se puede especificar fácilmente usando otro tipo de utilidad, Omit , que nos permite crear un nuevo tipo eliminando atributos de un tipo existente:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };que funciona como se esperaba:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, }; Para la segunda opción, tenemos que agregar explícitamente el atributo id encima del tipo Partial<Post> que definimos:

type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };que, de nuevo, nos da el resultado esperado:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Tipos de Unión
En la sección anterior, ya vimos cómo escribir una de las dos acciones que tiene nuestra tienda. Hagamos lo mismo con la segunda acción. Sabiendo que receiveNewPost se ve así:
function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }su tipo se puede definir de la siguiente manera:
type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };Fácil, ¿verdad?
Ahora echemos un vistazo a nuestro reductor: toma un state y una action (cuyo tipo aún no conocemos) y produce un nuevo State :
function reducer( state: State, action: any ): State { ... } Nuestra tienda tiene dos tipos diferentes de acciones: UpdatePostAction y ReceiveNewPostAction . Entonces, ¿cuál es el tipo de argumento de action ? Uno o el otro, ¿verdad? Cuando una variable puede aceptar más de un tipo A , B , C , etc., su tipo es una unión de tipos. Es decir, su tipo puede ser A , B o C , y así sucesivamente. Un tipo de unión es un tipo cuyos valores pueden ser de cualquiera de los tipos especificados en esa unión.
Así es como nuestro tipo de Action se puede definir como un tipo de unión:
type Action = UpdatePostAction | ReceiveNewPostAction; El fragmento anterior simplemente indica que una Action puede ser una instancia del tipo UpdatePostAction o una instancia del tipo ReceiveNewPostAction .
Si ahora usamos Action en nuestro reductor:
function reducer( state: State, action: Action ): State { ... }podemos ver cómo esta nueva versión de nuestro código basado, que está bien escrito, funciona sin problemas.
Cómo los tipos de unión eliminan los casos predeterminados
"Espera un segundo", podrías decir, "el enlace anterior no funciona correctamente, ¡el compilador está provocando un error!" De hecho, según TypeScript, nuestro reductor contiene código inalcanzable:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }¿Esperar lo? Déjame explicarte lo que está pasando aquí...
El tipo de unión Action que creamos es en realidad un tipo de unión discriminado. Un tipo de unión discriminado es un tipo de unión en el que todos sus tipos comparten un atributo común cuyo valor se puede utilizar para discriminar un tipo de otro.
En nuestro caso, los dos tipos de Action tienen un atributo de type cuyos valores son RECEIVE_NEW_POST para ReceiveNewPostAction y UPDATE_POST para UpdatePostAction . Dado que sabemos que una Action es, necesariamente, una instancia de una acción o de la otra, las dos ramas de nuestro switch cubren todas las posibilidades: o action.type es RECEIVE_NEW_POST o es UPDATE_POST . Por lo tanto, el return final es redundante y será inalcanzable.
Supongamos, entonces, que eliminamos ese return para corregir este error. ¿Ganamos algo más allá de eliminar código innecesario? La respuesta es sí. Si ahora agregamos un nuevo tipo de acción en nuestro código:
type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... }; de repente, la declaración de switch en nuestro reductor ya no cubrirá todos los escenarios posibles:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit } Esto significa que el reductor podría devolver implícitamente un valor undefined si lo invocamos usando una acción de tipo NEW_FEATURE , y eso es algo que no coincide con la firma de la función. Debido a esta discrepancia, TypeScript se queja y nos informa que nos falta una nueva rama para tratar con este nuevo tipo de acción.
Funciones polimórficas con tipos de retorno variable
Si has llegado hasta aquí, felicidades: has aprendido todo lo que necesitas hacer para mejorar el código fuente de tus aplicaciones JavaScript utilizando TypeScript. Y, como recompensa, les voy a compartir un “problema” que me encontré hace unos días y su solución. ¿Por qué? Porque TypeScript es un mundo complejo y fascinante y quiero mostrarte hasta qué punto esto es cierto.
Al comienzo de toda esta aventura, hemos visto que uno de los selectores que tenemos es getPostsInDay y como su tipo de retorno es una lista de ID de publicaciones:
function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }aunque el nombre sugiere que podría devolver una lista de publicaciones. ¿Por qué usé un nombre tan engañoso, te estarás preguntando? Bueno, imagine el siguiente escenario: suponga que desea que esta función pueda devolver una lista de ID de publicaciones o una lista de publicaciones reales, según el valor de uno de sus argumentos. Algo como esto:
const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );¿Podemos hacer eso en TypeScript? ¡Por supuesto lo hacemos! ¿Por qué más mencionaría esto de otra manera? Todo lo que tenemos que hacer es definir una función polimórfica cuyo resultado depende de los parámetros de entrada.
Entonces, la idea es que queremos dos versiones diferentes de la misma función. Se debe devolver una lista de PostId s si uno de los atributos es el id string . El otro debería devolver una lista de Post si ese mismo atributo es la string all .
Vamos a crear ambos:
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }Fácil, ¿verdad? ¡INCORRECTO! esto no funciona Según TypeScript, tenemos una "implementación de función duplicada".
Está bien, intentemos algo diferente, entonces. Combinemos las dos definiciones anteriores en una sola función:
function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }¿Se comporta como queremos? me temo que no...
Esto es lo que nos dice la firma de esta función: “ getPostsInDay es una función que toma dos argumentos, un state y un mode cuyos valores pueden ser id o all ; su tipo de retorno será una lista de PostId s o una lista de Post s.” En otras palabras, la definición de función anterior no especifica en ninguna parte que haya una relación entre el valor dado al argumento de mode y el tipo de retorno de la función. Y entonces código como este:
const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );es válido y no se comporta como queremos.
Vale, último intento. ¿Qué pasa si mezclamos nuestra intuición inicial, donde describimos firmas de funciones concretas, con una implementación única y válida?
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); } El fragmento anterior tiene una implementación de función válida que funciona, pero define dos firmas de funciones adicionales que vinculan valores concretos en mode con el tipo de retorno de la función.
Usando este enfoque, este código es válido:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );y este no:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );Conclusiones
En esta serie de posts hemos visto qué es TypeScript y cómo podemos aplicarlo en nuestros proyectos. Los tipos nos ayudan a documentar mejor el código proporcionando contexto semántico. Además, los tipos también añaden una capa extra de seguridad, ya que el compilador de TypeScript se encarga de validar que nuestro código encaje correctamente, al igual que los Legos.
En este punto ya tienes todas las herramientas necesarias para llevar la calidad de tu trabajo al siguiente nivel. ¡Mucha suerte en esta nueva aventura!
Imagen destacada de Mike Kenneally en Unsplash.
