Capítulo 4. Genéricos
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Hasta ahora, nuestro principal objetivo era tomar la flexibilidad inherente de JavaScript y encontrar una forma de formalizarla mediante el sistema de tipos. Añadimos tipos estáticos para un lenguaje tipado dinámicamente, para comunicar la intención, conseguir herramientas y detectar errores antes de quese produzcan.
Sin embargo, algunas partes de JavaScript no se preocupan realmente de los tipos estáticos. Por ejemplo, una función isKeyAvailableInObject
sólo debe comprobar si una clave está disponible en un objeto; no necesita saber nada de los tipos concretos. Para formalizar adecuadamente una función como ésta, podemos utilizar el sistema de tipos estructurales de TypeScript y describir un tipo muy amplio a cambio de información o un tipo muy estricto a cambio deflexibilidad.
Pero no queremos pagar cualquier precio. Queremos tanto flexibilidad como información. Los genéricos en TypeScript son justo la bala de plata que necesitamos. Podemos describir relaciones complejas y formalizar la estructura de los datos que aún no se han definido.
Los genéricos, junto con su pandilla de tipos mapeados, mapas de tipos, modificadores de tipos y tipos ayudantes, abren la puerta al metatipado, donde podemos crear nuevos tipos basados en los antiguos y mantener intactas las relaciones entre tipos, mientras los tipos recién generados cuestionan nuestro código original en busca de posibles errores.
Esta es la entrada a los conceptos avanzados de TypeScript. Pero no temas, no habrá dragones, a menos que los definamos.
4.1 Generalizar las firmas de funciones
Debate
Estás escribiendo una aplicación que almacena varios archivos de idioma (por ejemplo, subtítulos) en un objeto. Las claves son los códigos de idioma, y los valores son URLs. Cargas los archivos de idioma seleccionándolos mediante un código de idioma, que procede de alguna API o interfaz de usuario como string
. Para asegurarte de que el código de idioma es correcto y válido, añades una función isLanguageAvailable
que realiza una comprobación in
y establece el tipo correcto mediante un predicado de tipo:
type
Languages
=
{
de
:
URL
;
en
:
URL
;
pt
:
URL
;
es
:
URL
;
fr
:
URL
;
ja
:
URL
;
};
function
isLanguageAvailable
(
collection
:
Languages
,
lang
:
string
)
:
lang
is
keyof
Languages
{
return
lang
in
collection
;
}
function
loadLanguage
(
collection
:
Languages
,
lang
:
string
)
{
if
(
isLanguageAvailable
(
collection
,
lang
))
{
// lang is keyof Languages
collection
[
lang
];
// access ok!
}
}
La misma aplicación, un escenario diferente, un archivo totalmente distinto. Cargas datos multimedia en un elemento HTML: ya sea audio, vídeo o una combinación con ciertas animaciones en un elemento canvas
. Todos los elementos ya existen en la aplicación, pero tienes que seleccionar el correcto basándote en la entrada de una API. De nuevo, la selección viene como string
, y escribes una función isElementAllowed
para asegurarte de que la entrada es realmente una clave válida de tu colección AllowedElements
:
type
AllowedElements
=
{
video
:
HTMLVideoElement
;
audio
:
HTMLAudioElement
;
canvas
:
HTMLCanvasElement
;
};
function
isElementAllowed
(
collection
:
AllowedElements
,
elem
:
string
)
:
elem
is
keyof
AllowedElements
{
return
elem
in
collection
;
}
function
selectElement
(
collection
:
AllowedElements
,
elem
:
string
)
{
if
(
isElementAllowed
(
collection
,
elem
))
{
// elem is keyof AllowedElements
collection
[
elem
];
// access ok
}
}
No necesitas fijarte demasiado para ver que ambos escenarios son muy similares. Llaman especialmente la atención las funciones de protección de tipo. Si eliminamos toda la información de tipo y alineamos los nombres, son idénticos:
function
isAvailable
(
obj
,
key
)
{
return
key
in
obj
;
}
Ambos existen gracias a la información de tipo que obtenemos. No por los parámetros de entrada, sino por los predicados de tipo. En ambos casos, podemos saber más sobre los parámetros de entrada afirmando un tipo específico keyof
.
El problema es que los dos tipos de entrada de la colección son totalmente distintos y no se solapan. Excepto en el caso del objeto vacío, para el que no obtenemos tanta información valiosa si creamos un tipo keyof
. keyof {}
es en realidad never
.
Pero aquí hay alguna información de tipo que podemos generalizar. Sabemos que el primer parámetro de entrada es un objeto. Y el segundo es una clave de propiedad. Si esta comprobación evalúa a true
, sabemos que el primer parámetro es una clave del segundo parámetro.
Para generalizar esta función, podemos añadir un parámetro de tipo genérico a isAvailable
llamado Obj
, entre paréntesis angulares. Se trata de un marcador de posición para un tipo real que se sustituirá una vez que se utilice isAvailable
. Podemos utilizar este parámetro de tipo genérico como utilizaríamos AllowedElements
o Languages
y podemos añadir un predicado de tipo. Como Obj
puede ser sustituido por cualquier tipo, key
tiene que incluir todas las claves de propiedad posibles:string
, symbol
y number
:
function
isAvailable
<
Obj
>
(
obj
:
Obj
,
key
:
string
|
number
|
symbol
)
:
key
is
keyof
Obj
{
return
key
in
obj
;
}
function
loadLanguage
(
collection
:
Languages
,
lang
:
string
)
{
if
(
isAvailable
(
collection
,
lang
))
{
// lang is keyof Languages
collection
[
lang
];
// access ok!
}
}
function
selectElement
(
collection
:
AllowedElements
,
elem
:
string
)
{
if
(
isAvailable
(
collection
,
elem
))
{
// elem is keyof AllowedElements
collection
[
elem
];
// access ok
}
}
Y ahí lo tienes: una función que funciona en ambos escenarios, independientemente de los tipos que sustituyamos por Obj
. ¡Igual que funciona JavaScript! Seguimos obteniendo la misma funcionalidad, y obtenemos la información de tipo correcta. El acceso al índice se vuelve seguro, sin sacrificar la flexibilidad.
¿Y lo mejor? Podemos utilizar isAvailable
igual que utilizaríamos un equivalente JavaScript no tipado. Esto se debe a que TypeScript infiere los tipos de los parámetros de tipo genérico a través del uso. Y esto tiene algunos efectos secundarios. Puedes leer más sobre esto en la Receta 4.3.
4.3 Deshacerse de cualquier y desconocido
Solución
Utiliza parámetros de tipo genérico cuando llegues finalmente al tipo real; consulta la Receta 2.2 sobre la decisión entre any
y unknown
.
Debate
Cuando utilizamos genéricos, pueden parecer un sustituto de any
y unknown
. Tomemos una función identity
: su única función es devolver el valor pasado comoparámetro de entrada:
function
identity
(
value
:
any
)
:
any
{
return
value
;
}
let
a
=
identity
(
"Hello!"
);
let
b
=
identity
(
false
);
let
c
=
identity
(
2
);
Toma valores de cualquier tipo, y su tipo de retorno también puede ser cualquiera. Podemos escribir la misma función utilizando unknown
si queremos acceder de forma segura a las propiedades:
function
identity
(
value
:
unknown
)
:
unknown
{
return
value
;
}
let
a
=
identity
(
"Hello!"
);
let
b
=
identity
(
false
);
let
c
=
identity
(
2
);
Podemos incluso mezclar y combinar any
y unknown
, pero el resultado es siempre el mismo: se pierde la información de tipo. El tipo del valor de retorno es el que nosotros definamos.
Ahora escribamos la misma función con genéricos en lugar de any
o unknown
. Sus anotaciones de tipo dicen que el tipo genérico es también el tipo de retorno:
function
identity
<
T
>
(
t
:
T
)
:
T
{
return
t
;
}
Podemos utilizar esta función para pasar cualquier valor y ver qué tipo infiere TypeScript:
let
a
=
identity
(
"Hello!"
);
// a is string
let
b
=
identity
(
2000
);
// b is number
let
c
=
identity
({
a
:
2
});
// c is { a: number }
La asignación a un enlace con const
en lugar de let
da resultados ligeramente diferentes:
const
a
=
identity
(
"Hello!"
);
// a is "Hello!"
const
b
=
identity
(
2000
);
// b is 2000
const
c
=
identity
({
a
:
2
});
// c is { a: number }
Para los tipos primitivos, TypeScript sustituye el parámetro de tipo genérico por el tipo real. Podemos hacer un gran uso de esto en escenarios más avanzados.
Con los genéricos de TypeScript, también es posible anotar el parámetro de tipo genérico:
const
a
=
identity
<
string
>
(
"Hello!"
);
// a is string
const
b
=
identity
<
number
>
(
2000
);
// b is number
const
c
=
identity
<
{
a
:
2
}
>
({
a
:
2
});
// c is { a: 2 }
Si este comportamiento te recuerda a la anotación e inferencia descritas en la Receta 3.4, tienes toda la razón. Es muy parecido, pero con parámetros de tipo genérico en las funciones.
Al utilizar genéricos sin restricciones, podemos escribir funciones que funcionen con valores de cualquier tipo. En su interior, se comportan como unknown
, lo que significa que podemos hacer guardias de tipo para acotar el tipo. La mayor diferencia es que, una vez que utilizamos la función, sustituimos nuestros genéricos por tipos reales, sin perder en absoluto información sobre la tipificación.
Esto nos permite ser un poco más claros con nuestros tipos que limitándonos a permitirlo todo. Esta función pairs
toma dos argumentos y crea una tupla:
function
pairs
(
a
:
unknown
,
b
:
unknown
)
:
[
unknown
,
unknown
]
{
return
[
a
,
b
];
}
const
a
=
pairs
(
1
,
"1"
);
// [unknown, unknown]
Con parámetros de tipo genérico, obtenemos un bonito tipo tupla:
function
pairs
<
T
,
U
>
(
a
:
T
,
b
:
U
)
:
[
T
,
U
]
{
return
[
a
,
b
];
}
const
b
=
pairs
(
1
,
"1"
);
// [number, string]
Utilizando el mismo parámetro de tipo genérico, podemos asegurarnos de que sólo obtenemos tuplas en las que cada elemento es del mismo tipo:
function
pairs
<
T
>
(
a
:
T
,
b
:
T
)
:
[
T
,
T
]
{
return
[
a
,
b
];
}
const
c
=
pairs
(
1
,
"1"
);
// ^
// Argument of type 'string' is not assignable to parameter of type 'number'
Entonces, ¿debes utilizar genéricos en todas partes? No necesariamente. Este capítulo incluye muchas soluciones que dependen de obtener la información de tipo correcta en el momento adecuado. Cuando te conformes con un conjunto más amplio de valores y puedas confiar en que los subtipos son compatibles, no necesitas utilizar genéricos en absoluto. Si tienes any
y unknown
en tu código, piensa si necesitas el tipo real en algún momento. Añadir un parámetro de tipo genérico en su lugar podría ayudarte.
4.4 Comprender la instanciación genérica
Solución
Recuerda que los valores de un tipo genérico pueden ser sustituidos -explícita e implícitamente- por diversos subtipos. Escribe código compatible con los subtipos.
Debate
Creas una lógica de filtro para tu aplicación. Tienes diferentes reglas de filtro que puedes combinar utilizando los combinadores de "and" | "or"
. También puedes encadenar reglas de filtro regulares con el resultado de filtros combinatorios. Creas tus tipos basándote en estecomportamiento:
type
FilterRule
=
{
field
:
string
;
operator
:
string
;
value
:
any
;
};
type
CombinatorialFilter
=
{
combinator
:
"and"
|
"or"
;
rules
:
FilterRule
[];
};
type
ChainedFilter
=
{
rules
:
(
CombinatorialFilter
|
FilterRule
)[];
};
type
Filter
=
CombinatorialFilter
|
ChainedFilter
;
Ahora quieres escribir una función reset
que, basándose en un filtro ya proporcionado, restablezca todas las reglas. Utiliza guardas de tipo para distinguir entre CombinatorialFilter
y ChainedFilter
:
function
reset
(
filter
:
Filter
)
:
Filter
{
if
(
"combinator"
in
filter
)
{
// filter is CombinatorialFilter
return
{
combinator
:
"and"
,
rules
:
[]
};
}
// filter is ChainedFilter
return
{
rules
:
[]
};
}
const
filter
:
CombinatorialFilter
=
{
rules
:
[],
combinator
:
"or"
};
const
resetFilter
=
reset
(
filter
);
// resetFilter is Filter
El comportamiento es el que buscas, pero el tipo de retorno de reset
es demasiado amplio. Cuando pasamos un CombinatorialFilter
, debemos asegurarnos de que el filtro de retorno es también un CombinatorialFilter
. Aquí es del tipo unión, tal como indica la firma de nuestra función. Pero quieres asegurarte de que si pasas un filtro de un tipo determinado, también obtienes el mismo tipo de retorno. Así que sustituyes el tipo de unión amplio por un parámetro de tipo genérico que está restringido a Filter
. El tipo de retorno funciona según lo previsto, pero la implementación de tu función arroja errores:
function
reset
<
F
extends
Filter
>
(
filter
:
F
)
:
F
{
if
(
"combinator"
in
filter
)
{
return
{
combinator
:
"and"
,
rules
:
[]
};
// ^ '{ combinator: "and"; rules: never[]; }' is assignable to
// the constraint of type 'F', but 'F' could be instantiated
// with a different subtype of constraint 'Filter'.
}
return
{
rules
:
[]
};
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
// but 'F' could be instantiated with a different subtype of
// constraint 'Filter'.
}
const
resetFilter
=
reset
(
filter
);
// resetFilter is CombinatorialFilter
Mientras que tú quieres diferenciar entre dos partes de una unión, TypeScript piensa de forma más amplia. Sabe que puedes pasar un objeto estructuralmente compatible con Filter
, pero que tiene más propiedades y, por tanto, es un subtipo.
Esto significa que puedes llamar a reset
con F
instanciado a un subtipo, y tu programa anularía alegremente todas las propiedades sobrantes. Esto es incorrecto, y TypeScript te lo dice:
const
onDemandFilter
=
reset
({
combinator
:
"and"
,
rules
:
[],
evaluated
:
true
,
result
:
false
,
});
/* filter is {
combinator: "and";
rules: never[];
evaluated: boolean;
result: boolean;
}; */
Supera esto escribiendo código amigable con los subtipos. Clona el objeto de entrada (que siga siendo del tipo F
), establece las propiedades que deban modificarse en consecuencia y devuelve algo que siga siendo del tipo F
:
function
reset
<
F
extends
Filter
>
(
filter
:
F
)
:
F
{
const
result
=
{
...
filter
};
// result is F
result
.
rules
=
[];
if
(
"combinator"
in
result
)
{
result
.
combinator
=
"and"
;
}
return
result
;
}
const
resetFilter
=
reset
(
filter
);
// resetFilter is CombinatorialFilter
Los tipos genéricos pueden ser uno de muchos en una unión, pero pueden ser mucho, mucho más. El sistema de tipos estructurales de TypeScript te permite trabajar con una gran variedad de subtipos, y tu código debe reflejarlo.
He aquí un escenario diferente pero con un resultado similar. Quieres crear una estructura de datos en forma de árbol y escribir un tipo recursivo que almacene todos los elementos del árbol. Este tipo puede sersubtipado, así que escribes una función createRootItem
con un parámetro de tipo genérico, ya que quieres instanciarlo con el subtipo correcto:
type
TreeItem
=
{
id
:
string
;
children
:
TreeItem
[];
collapsed
?:
boolean
;
};
function
createRootItem
<
T
extends
TreeItem
>
()
:
T
{
return
{
id
:
"root"
,
children
:
[],
};
// '{ id: string; children: never[]; }' is assignable to the constraint
// of type 'T', but 'T' could be instantiated with a different subtype
// of constraint 'TreeItem'.(2322)
}
const
root
=
createRootItem
();
// root is TreeItem
Obtenemos un error similar al anterior, ya que no podemos afirmar que el valor de retorno será compatible con todos los subtipos. Para resolver este problema, ¡deshazte del genérico! Sabemos cómo será el tipo de retorno: es un TreeItem
:
function
createRootItem
()
:
TreeItem
{
return
{
id
:
"root"
,
children
:
[],
};
}
Las soluciones más sencillas suelen ser las mejores. Pero ahora quieres ampliar tu programa pudiendo adjuntar hijos de tipo o subtipo TreeItem
a una raíz recién creada. Aún no hemos añadido ningún genérico y estamos algo insatisfechos:
function
attachToRoot
(
children
:
TreeItem
[])
:
TreeItem
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([]);
// TreeItem
root
es del tipo TreeItem
, pero perdemos toda información sobre los hijos subtipados. Incluso si añadimos un parámetro de tipo genérico sólo para los hijos, restringido a TreeItem
, no conservamos esta información sobre la marcha:
function
attachToRoot
<
T
extends
TreeItem
>
(
children
:
T
[])
:
TreeItem
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([
{
id
:
"child"
,
children
:
[],
collapsed
:
false
,
marked
:
true
,
},
]);
// root is TreeItem
Cuando empezamos a añadir un tipo genérico como tipo de retorno, nos encontramos con los mismos problemas que antes. Para resolver este problema, tenemos que separar el tipo de elemento raíz del tipo de elemento hijo, abriendo TreeItem
para que sea un genérico, donde podemos establecer que Children
sea un subtipo de TreeItem
.
Como queremos evitar cualquier referencia circular, tenemos que establecer Children
en un valor por defecto BaseTreeItem
, de modo que podamos utilizar TreeItem
tanto como restricción para Children
como para attachToRoot
:
type
BaseTreeItem
=
{
id
:
string
;
children
:
BaseTreeItem
[];
};
type
TreeItem
<
Children
extends
TreeItem
=
BaseTreeItem
>
=
{
id
:
string
;
children
:
Children
[];
collapsed
?:
boolean
;
};
function
attachToRoot
<
T
extends
TreeItem
>
(
children
:
T
[])
:
TreeItem
<
T
>
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([
{
id
:
"child"
,
children
:
[],
collapsed
:
false
,
marked
:
true
,
},
]);
/*
root is TreeItem<{
id: string;
children: never[];
collapsed: false;
marked: boolean;
}>
*/
De nuevo, escribimos subtipos amigables y tratamos nuestros parámetros de entrada como propios, en lugar de hacer suposiciones.
4.5 Generar nuevos tipos de objetos
Solución
Utiliza tipos genéricos mapeados para crear nuevos tipos de objeto basados en el tipo original.
Debate
Volvamos a la juguetería de la Receta 3.1. Gracias a los tipos de unión, los tipos de intersección y los tipos de unión discriminados, pudimos modelar nuestros datos bastante bien:
type
ToyBase
=
{
name
:
string
;
description
:
string
;
minimumAge
:
number
;
};
type
BoardGame
=
ToyBase
&
{
kind
:
"boardgame"
;
players
:
number
;
};
type
Puzzle
=
ToyBase
&
{
kind
:
"puzzle"
;
pieces
:
number
;
};
type
Doll
=
ToyBase
&
{
kind
:
"doll"
;
material
:
"plush"
|
"plastic"
;
};
type
Toy
=
Doll
|
Puzzle
|
BoardGame
;
En algún lugar de nuestro código, necesitamos agrupar todos los juguetes de nuestro modelo en una estructura de datos que puede describirse mediante un tipo llamado GroupedToys
. GroupedToys
tiene una propiedad para cada categoría (o "kind"
) y una matriz Toy
como valor. Una función groupToys
toma una lista sin ordenar de juguetes y los agrupa por tipo:
type
GroupedToys
=
{
boardgame
:
Toy
[];
puzzle
:
Toy
[];
doll
:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
}
return
groups
;
}
Ya hay algunas sutilezas en este código. En primer lugar, utilizamos una anotación de tipo explícita al declarar groups
. Esto garantiza que no olvidamos ninguna categoría. Además, como las claves de GroupedToys
son las mismas que la unión de los tipos de "kind"
en Toy
, podemos indexar fácilmente el acceso a groups
mediante toy.kind
.
Pasan los meses y los sprints, y necesitamos volver a tocar nuestro modelo. La juguetería vende ahora ladrillos de juguete encajables originales o quizá de otros proveedores. Conectamos el nuevo tipo Bricks
a nuestro modelo Toy
:
type
Bricks
=
ToyBase
&
{
kind
:
"bricks"
,
pieces
:
number
;
brand
:
string
;
}
type
Toy
=
Doll
|
Puzzle
|
BoardGame
|
Bricks
;
Como groupToys
también tiene que tratar con Bricks
, obtenemos un bonito error porque GroupedToys
no tiene ni idea de un tipo "bricks"
:
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
// ^- Element implicitly has an 'any' type because expression
// of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
// be used to index type 'GroupedToys'.
// Property 'bricks' does not exist on type 'GroupedToys'.(7053)
}
return
groups
;
}
Este es un comportamiento deseado en TypeScript: saber cuándo los tipos ya no coinciden. Esto debería llamar nuestra atención. Vamos a actualizar GroupedToys
y groupToys
:
type
GroupedToys
=
{
boardgame
:
Toy
[];
puzzle
:
Toy
[];
doll
:
Toy
[];
bricks
:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
bricks
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
}
return
groups
;
}
Hay una cosa molesta: la tarea de agrupar juguetes es siempre la misma. Por mucho que cambie nuestro modelo, siempre seleccionaremos por tipo y empujaremos a un array. Tendríamos que actualizar groups
con cada cambio, pero si cambiamos la forma de pensar sobre los grupos, podemos optimizar el cambio. En primer lugar, cambiamos el tipo GroupedToys
para que incluya propiedades opcionales. En segundo lugar, inicializamos cada grupo con una matriz vacía si aún no se ha producido ninguna inicialización:
type
GroupedToys
=
{
boardgame
?:
Toy
[];
puzzle
?:
Toy
[];
doll
?:
Toy
[];
bricks
?:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{};
for
(
let
toy
of
toys
)
{
// Initialize when not available
groups
[
toy
.
kind
]
=
groups
[
toy
.
kind
]
??
[];
groups
[
toy
.
kind
]
?
.
push
(
toy
);
}
return
groups
;
}
Ya no necesitamos mantener groupToys
. Lo único que necesita mantenimiento es el tipo GroupedToys
. Si observamos detenidamente GroupedToys
, veremos que existe una relación implícita con Toy
. Cada clave de propiedad forma parte de Toy["kind"]
. Hagamos explícita esta relación. Con un tipo mapeado, creamos un nuevo tipo de objeto basado en cada tipo de Toy["kind"]
.
Toy["kind"]
es una unión de literales de cadena: "boardgame" | "puzzle" | "doll" | "bricks"
. Como tenemos un conjunto muy reducido de cadenas, cada elemento de esta unión se utilizará como su propia clave de propiedad. Deja que eso se asimile por un momento: podemos utilizar un tipo para que sea una clave de propiedad de un tipo recién generado. Cada propiedad tiene un modificador de tipo opcional y apunta a un Toy[]
:
type
GroupedToys
=
{
[
k
in
Toy
[
"kind"
]]
?:
Toy
[];
};
¡Fantástico! Cada vez que cambiamos Toy
, cambiamos inmediatamente Toy[]
. Nuestro código no necesita ningún cambio; podemos seguir agrupando por tipo como antes.
Éste es un patrón que podemos generalizar. Vamos a crear un tipo Group
que tome una colección y la agrupe mediante un selector específico. Queremos crear un tipo genérico con dos parámetros de tipo:
-
El
Collection
puede ser cualquier cosa. -
El
Selector
, una clave deCollection
, para que pueda crear las propiedades respectivas.
Nuestro primer intento sería tomar lo que teníamos en GroupedToys
y sustituir los tipos concretos por parámetros de tipo. Esto crea lo que necesitamos, pero también provoca un error:
// How to use it
type
GroupedToys
=
Group
<
Toy
,
"kind"
>
;
type
Group
<
Collection
,
Selector
extends
keyof
Collection
>
=
{
[
x
in
Collection
[
Selector
]]
?:
Collection
[];
// ^ Type 'Collection[Selector]' is not assignable
// to type 'string | number | symbol'.
// Type 'Collection[keyof Collection]' is not
// assignable to type 'string | number | symbol'.
// Type 'Collection[string] | Collection[number]
// | Collection[symbol]' is not assignable to
// type 'string | number | symbol'.
// Type 'Collection[string]' is not assignable to
// type 'string | number | symbol'.(2322)
};
TypeScript nos advierte de que Collection[string] | Collection[number] | Collection[symbol]
puede dar lugar a cualquier cosa, no sólo a cosas que puedan utilizarse como clave. Eso es cierto, y tenemos que prepararnos para ello. Tenemos dos opciones.
Primero, utiliza una restricción de tipo en Collection
que apunte a Record<string, any>
. Record
es un tipo de utilidad que genera un nuevo objeto en el que el primer parámetro te da todas las claves y el segundo parámetro te da los tipos:
// This type is built-in!
type
Record
<
K
extends
string
|
number
|
symbol
,
T
>
=
{
[
P
in
K
]
:
T
;
};
Esto eleva Collection
a un objeto comodín, desactivando efectivamente la comprobación de tipo de Groups
. Esto está bien porque si algo fuera un tipo inutilizable para una clave de propiedad, TypeScript lo desechará de todos modos. Así que el Group
final tiene dos parámetros de tipo restringido:
type
Group
<
Collection
extends
Record
<
string
,
any
>
,
Selector
extends
keyof
Collection
>
=
{
[
x
in
Collection
[
Selector
]]
:
Collection
[];
};
La segunda opción es hacer una comprobación por cada clave para ver si es una clave de cadena válida. Podemos utilizar un tipo condicional para ver si Collection[Selector]
es de hecho un tipo válido para una clave. En caso contrario, eliminaríamos este tipo eligiendo never
. Los tipos condicionales son su propia bestia, y lo abordamos ampliamente en la Receta 5.4:
type
Group
<
Collection
,
Selector
extends
keyof
Collection
>
=
{
[
k
in
Collection
[
Selector
]
extends
string
?
Collection
[
Selector
]
:
never
]
?:
Collection
[];
};
Observa que hemos eliminado el modificador de tipo opcional. Lo hacemos porque hacer que las claves sean opcionales no es tarea de la agrupación. Para eso tenemos otro tipo Partial<T>
otro tipo mapeado que hace que todas las propiedades de un tipo de objeto sean opcionales:
// This type is built-in!
type
Partial
<
T
>
=
{
[
P
in
keyof
T
]
?:
T
[
P
]
};
Independientemente del ayudante Group
que crees, ahora puedes crear un objeto GroupedToys
diciéndole a TypeScript que quieres un Partial
(cambiando todo a propiedades opcionales) de un Group
de Toys
por "kind"
:
type
GroupedToys
=
Partial
<
Group
<
Toy
,
"kind"
>>
;
4.6 Modificar objetos con firmas de aserción
Solución
Utiliza firmas de aserción para cambiar de tipo independientemente de las declaraciones if
y switch
.
Debate
JavaScript es un lenguaje muy flexible. Sus características de tipado dinámico te permiten cambiar objetos en tiempo de ejecución, añadiendo nuevas propiedades sobre la marcha. Y los desarrolladores utilizan esto. Hay situaciones en las que, por ejemplo, pasas por encima de una colección de elementos y necesitas afirmar ciertas propiedades. Entonces almacenas una propiedad checked
y la pones a true
, sólo para saber que has superado una determinada marca:
function
check
(
person
:
any
)
{
person
.
checked
=
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
check
(
person
);
// person now has the checked property
person
.
checked
;
// this is true!
Quieres reflejar este comportamiento en el sistema de tipos; de lo contrario, tendrías que hacer constantemente comprobaciones adicionales si determinadas propiedades están en un objeto, aunque puedas estar seguro de que existen.
Una forma de afirmar que existen determinadas propiedades son, bueno, las afirmaciones de tipo. Decimos que, en un momento determinado, esta propiedad tiene un tipo diferente:
(
person
as
typeof
person
&
{
checked
:
boolean
}).
checked
=
true
;
Bien, pero tendrías que hacer esta afirmación de tipo una y otra vez, ya que no cambian el tipo original de person
. Otra forma de afirmar que ciertas propiedades están disponibles es crear predicados de tipo, como los que se muestran en la Receta 3.5:
function
check
<
T
>
(
obj
:
T
)
:
obj
is
T
&
{
checked
:
true
}
{
(
obj
as
T
&
{
checked
:
boolean
}).
checked
=
true
;
return
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
if
(
check
(
person
))
{
person
.
checked
;
// checked is true!
}
Sin embargo, esta situación es un poco diferente, lo que hace que la función check
parezca torpe: tienes que hacer una condición adicional y devolver true
en la función de predicado. Esto no parece correcto.
Afortunadamente, TypeScript tiene otra técnica que podemos aprovechar en situaciones como ésta: las firmas de aserción. Las firmas de aserción pueden cambiar el tipo de un valor en el flujo de control, sin necesidad de condicionales. Se han modelado para la función assert
de Node.js, que toma una condición y lanza un error si no es verdadera. Esto significa que, después de llamar a assert
, puedes tener más información que antes. Por ejemplo, si llamas a assert
y compruebas si un valor tiene el tipo string
, sabrás que después de esta función assert
el valor debería ser string
:
function
assert
(
condition
:
any
,
msg
?:
string
)
:
asserts
condition
{
if
(
!
condition
)
{
throw
new
Error
(
msg
);
}
}
function
yell
(
str
:
any
)
{
assert
(
typeof
str
===
"string"
);
// str is string
return
str
.
toUpperCase
();
}
Ten en cuenta que la función se cortocircuita si la condición es falsa. Lanza un error, el caso never
. Si esta función pasa, puedes afirmar realmente la condición.
Aunque las firmas de aserción se han modelado para la función de aserción de Node.js, puedes asertar cualquier tipo que desees. Por ejemplo, puedes tener una función que tome cualquier valor para una suma, pero que asevere que los valores deben ser number
para continuar:
function
assertNumber
(
val
:
any
)
:
asserts
val
is
number
{
if
(
typeof
val
!==
"number"
)
{
throw
Error
(
"value is not a number"
);
}
}
function
add
(
x
:
unknown
,
y
:
unknown
)
:
number
{
assertNumber
(
x
);
// x is number
assertNumber
(
y
);
// y is number
return
x
+
y
;
}
Todos los ejemplos que encuentres sobre firmas de aserción se basan en aserciones y cortocircuitos con errores. Pero podemos utilizar la misma técnica para decirle a TypeScript que hay más propiedades disponibles. Escribimos una función muy similar a check
en la función de predicado anterior, pero esta vez no necesitamos devolver true
. Establecemos la propiedad, y como en JavaScript los objetos se pasan por valor, podemos afirmar que después de llamar a esta función cualquier cosa que pasemos tiene una propiedad checked
, que es true
:
function
check
<
T
>
(
obj
:
T
)
:
asserts
obj
is
T
&
{
checked
:
true
}
{
(
obj
as
T
&
{
checked
:
boolean
}).
checked
=
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
check
(
person
);
Y con eso, podemos modificar el tipo de un valor sobre la marcha. Es una técnica poco conocida que puede ayudarte mucho.
4.7 Asignar tipos con mapas de tipos
Solución
Almacena todos los subtipos en un mapa de tipos, amplíalos con acceso a índices y utiliza tipos mapeados como Partial<T>
.
Debate
Las funciones de fábrica son estupendas si quieres crear variantes de objetos complejos a partir de cierta información básica. Un escenario que quizá conozcas del JavaScript del navegador es la creación de elementos. La función document.createElement
acepta el nombre de etiqueta de un elemento, y obtienes un objeto en el que puedes modificar todas laspropiedades necesarias.
Quieres aderezar esta creación con una bonita función de fábrica que llamas createElement
. No sólo toma el nombre de la etiqueta del elemento, sino que también crea una lista de propiedades para que no tengas que establecer cada propiedad individualmente:
// Using create Element
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// b is HTMLVideoElement
const
b
=
createElement
(
"video"
,
{
src
:
"/movie.mp4"
,
autoplay
:
true
});
// c is HTMLElement
const
c
=
createElement
(
"my-element"
);
Si quieres crear buenos tipos para ello, debes tener en cuenta dos cosas:
-
Asegúrate de crear sólo elementos HTML válidos.
-
Proporciona un tipo que acepte un subconjunto de propiedades de un elemento HTML.
Ocupémonos primero de los elementos HTML válidos. Hay unos 140 elementos HTML posibles, que son muchos. Cada uno de esos elementos tiene un nombre de etiqueta, que puede representarse como una cadena, y un objeto prototipo respectivo en el DOM. Utilizando la lib dom en tu tsconfig.json, TypeScript tiene información sobre esos objetos prototipo en forma de tipos. Y puedes averiguar los 140 nombres de elementos.
Una buena forma de proporcionar una correspondencia entre los nombres de las etiquetas de los elementos y los objetos prototipo es utilizar un mapa de tipos. Un mapa de tipos es una técnica en la que tomas un alias de tipo o interfaz y dejas que las claves apunten a las respectivas variantes de tipo. Luego puedes obtener la variante de tipo correcta utilizando el acceso al índice de un tipo literal de cadena:
type
AllElements
=
{
a
:
HTMLAnchorElement
;
div
:
HTMLDivElement
;
video
:
HTMLVideoElement
;
//... and ~140 more!
};
// HTMLAnchorElement
type
A
=
AllElements
[
"a"
];
Se parece al acceso a las propiedades de un objeto JavaScript mediante el acceso a índices, pero recuerda que seguimos trabajando a nivel de tipo. Esto significa que el acceso al índice puede ser amplio:
type
AllElements
=
{
a
:
HTMLAnchorElement
;
div
:
HTMLDivElement
;
video
:
HTMLVideoElement
;
//... and ~140 more!
};
// HTMLAnchorElement | HTMLDivELement
type
AandDiv
=
AllElements
[
"a"
|
"div"
];
Utilicemos este mapa para escribir la función createElement
. Utilizamos un parámetro de tipo genérico restringido a todas las claves de AllElements
, lo que nos permite pasar sólo elementos HTML válidos:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
)
:
AllElements
[
T
]
{
return
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
}
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
);
Utiliza aquí los genéricos para fijar un literal de cadena a un tipo literal, que podemos utilizar para indexar la variante correcta del elemento HTML desde el mapa de tipos. Ten en cuenta también que utilizar document.createElement
requiere dos afirmaciones de tipo. Una amplía el conjunto (T
a string
), y la otra lo reduce (HTMLElement
a AllElements[T]
). Ambas aserciones indican que tenemos que tratar con una API fuera de nuestro control, como se establece en la Receta 3.9. Nos ocuparemos de las afirmaciones más adelante.
Ahora queremos proporcionar la opción de pasar propiedades extra para dichos elementos HTML, para establecer un href
en un HTMLAnchorElement
, etc. Todas las propiedades ya están en las respectivas variantes de HTMLElement
, pero son obligatorias, no opcionales. Podemos hacer que todas las propiedades sean opcionales con el tipo incorporado Partial<T>
. Es un tipo mapeado que toma todas las propiedades de un determinado tipo y les añade un modificador de tipo:
type
Partial
<
T
>
=
{
[
P
in
keyof
T
]
?:
T
[
P
]
};
Ampliamos nuestra función con un argumento opcional props
que es un Partial
del elemento indexado de AllElements
. De este modo, sabemos que si pasamos un "a"
, sólo podremos establecer propiedades que estén disponibles en HTMLAnchorElement
:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
]
{
const
elem
=
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
return
Object
.
assign
(
elem
,
props
);
}
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
const
x
=
createElement
(
"a"
,
{
src
:
"https://fettblog.eu"
});
// ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)
¡Fantástico! Ahora te toca a ti descubrir los 140 elementos HTML. O no. Alguien ya hizo el trabajo y puso HTMLElementTagNameMap
en lib.dom.ts. Así que utilicemos esto en su lugar:
function
createElement
<
T
extends
keyof
HTMLElementTagNameMap
>
(
tag
:
T
,
props
?:
Partial
<
HTMLElementTagNameMap
[
T
]
>
)
:
HTMLElementTagNameMap
[
T
]
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
Ésta es también la interfaz que utiliza document.createElement
, por lo que no hay fricción entre tu función de fábrica y la incorporada. No son necesarias aserciones adicionales.
Sólo hay una advertencia. Estás restringido a los 140 elementos proporcionados por HTMLElementTagNameMap
. ¿Y si quieres crear elementos SVG, o componentes web que puedan tener nombres de elementos totalmente personalizados? De repente, tu función de fábrica estádemasiado limitada.
Para permitir más -como hace document.createElement
- tendríamos que volver a añadir todas las cadenas posibles a la mezcla. HTMLElementTagNameMap
es una interfaz. Así que podemos utilizar la fusión de declaraciones para ampliar la interfaz con una firma indexada, en la que asignemos todas las cadenas restantes a HTMLUnknownElement
:
interface
HTMLElementTagNameMap
{
[
x
:
string
]
:
HTMLUnknownElement
;
};
function
createElement
<
T
extends
keyof
HTMLElementTagNameMap
>
(
tag
:
T
,
props
?:
Partial
<
HTMLElementTagNameMap
[
T
]
>
)
:
HTMLElementTagNameMap
[
T
]
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// b is HTMLUnknownElement
const
b
=
createElement
(
"my-element"
);
Ahora tenemos todo lo que queremos:
-
Una gran función de fábrica para crear elementos HTML tipados
-
Posibilidad de establecer las propiedades de los elementos con un solo objeto de configuración
-
La flexibilidad para crear más elementos de los definidos
Esto último está muy bien, pero ¿y si sólo quieres permitir componentes web? Los componentes web tienen una convención: deben tener un guión en su nombre de etiqueta. Podemos modelar esto utilizando un tipo mapeado en un tipo literal de plantilla de cadena. Aprenderás todo sobre los tipos literales de plantilla de cadena en el Capítulo 6.
Por ahora, lo único que necesitas saber es que creamos un conjunto de cadenas donde el patrón es cualquier cadena seguida de un guión seguido de cualquier cadena. Esto es suficiente para asegurarnos de que sólo pasamos nombres de elementos correctos.
Los tipos mapeados sólo funcionan con alias de tipo, no con declaraciones de interfaz, por lo que tenemos que volver a definir un tipo AllElements
:
type
AllElements
=
HTMLElementTagNameMap
&
{
[
x
in
`
${
string
}
-
${
string
}
`
]
:
HTMLElement
;
};
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
]
{
const
elem
=
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
return
Object
.
assign
(
elem
,
props
);
}
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// OK
const
b
=
createElement
(
"my-element"
);
// OK
const
c
=
createElement
(
"thisWillError"
);
// ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)
Fantástico. Con el tipo AllElements
también recuperamos las aserciones de tipo, que no nos gustan tanto. En ese caso, en lugar de aserciones, también podemos utilizar una sobrecarga de función, definiendo dos declaraciones: una para nuestros usuarios y otra para que implementemos la función. Puedes aprender más sobre esta técnica de sobrecarga de funciones en las Recetas 2.6 y 12.7:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
];
function
createElement
(
tag
:
string
,
props
?:
Partial
<
HTMLElement
>
)
:
HTMLElement
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
Ya está todo listo. Definimos un mapa de tipos con tipos mapeados y firmas de índices, utilizando parámetros de tipos genéricos para ser muy explícitos sobre nuestras intenciones. Una gran combinación de múltiples herramientas en nuestro cinturón de herramientas TypeScript.
4.8 Utilizar EsteTipo para definir esto en los objetos
Solución
Utiliza el ThisType<T>
genérico incorporado para definir el this
correcto.
Debate
Los frameworks como VueJS dependen mucho de las funciones de fábrica, en las que pasas un objeto de configuración completo para definir los datos iniciales, las propiedades computadas y los métodos de cada instancia. Quieres crear un comportamiento similar para los componentes de tu aplicación. La idea es proporcionar un objeto de configuración con tres propiedades:
- Una función
data
-
El valor devuelto son los datos iniciales de la instancia. No debes tener acceso a ninguna otra propiedad del objeto de configuración en esta función.
- Una propiedad
computed
-
Esto es para las propiedades calculadas, que se basan en los datos iniciales. Las propiedades calculadas se declaran utilizando funciones. Pueden acceder a los datos iniciales igual quelas propiedades normales.
- Una propiedad
methods
-
Los métodos se pueden llamar y pueden acceder tanto a las propiedades computadas como a los datos iniciales. Cuando los métodos acceden a propiedades calculadas, lo hacen como si fueran propiedades normales: sin necesidad de llamar a la función.
Observando el objeto de configuración en uso, hay tres formas distintas de interpretar this
. En data
, this
no tiene ninguna propiedad. En computed
, cada función puede acceder al valor de retorno de data
a través de this
como si formara parte de su objeto. En methods
, cada método puede acceder a las propiedades calculadas y a data
a través de this
del mismo modo:
const
instance
=
create
({
data
()
{
return
{
firstName
:
"Stefan"
,
lastName
:
"Baumgartner"
,
};
},
computed
:
{
fullName
()
{
// has access to the return object of data
return
this
.
firstName
+
" "
+
this
.
lastName
;
},
},
methods
:
{
hi
()
{
// use computed properties just like normal properties
alert
(
this
.
fullName
.
toLowerCase
());
},
},
});
Este comportamiento es especial, pero no infrecuente. Y con un comportamiento así, sin duda queremos confiar en los buenos tipos.
Nota
En esta lección nos centraremos sólo en los tipos, no en la implementación real, ya que eso excedería el ámbito de este capítulo.
Vamos a crear tipos para cada propiedad. Definimos un tipo Options
, que vamos a refinar paso a paso. Primero está la función data
. data
puede ser definida por el usuario, así que queremos especificar data
utilizando un parámetro de tipo genérico. Los datos que buscamos se especifican mediante el tipo de retorno de la función data
:
type
Options
<
Data
>
=
{
data
(
this
:
{})
?:
Data
;
};
Así, una vez que especificamos un valor de retorno real en la función data
, el marcador de posición Data
se sustituye por el tipo del objeto real. Ten en cuenta que también definimos this
para que apunte al objeto vacío, lo que significa que no tenemos acceso a ninguna otra propiedad del objeto de configuración.
A continuación, definimos computed
. computed
es un objeto de funciones. Añadimos otro parámetro de tipo genérico llamado Computed
y dejamos que el valor de Computed
se tipifique mediante el uso. Aquí, this
cambia a todas las propiedades de Data
. Como no podemos establecer this
como hacemos en la función data
, podemos utilizar el tipo auxiliar incorporado ThisType
y establecerlo en el parámetro de tipo genérico Data
:
type
Options
<
Data
,
Computed
>
=
{
data
(
this
:
{})
?:
Data
;
computed
?:
Computed
&
ThisType
<
Data
>
;
};
Esto nos permite acceder, por ejemplo, a this.firstName
, como en el ejemplo anterior. Por último, pero no por ello menos importante, queremos especificar methods
. methods
vuelve a ser especial, ya que no sólo obtienes acceso a Data
a través de this
, sino también a todos los métodos y a todas las propiedades computadas como propiedades.
Computed
contiene todas las propiedades calculadas como funciones. Sin embargo, necesitaríamos su valor, más concretamente, su valor de retorno. Si accedemos a fullName
mediante el acceso a propiedades, esperamos que sea un string
.
Para ello, creamos un tipo ayudante llamado MapFnToProp
. Toma un tipo que es un objeto de funciones y lo mapea a los tipos de los valores de retorno. El tipo de ayuda incorporado ReturnType
es perfecto para este escenario:
// An object of functions ...
type
FnObj
=
Record
<
string
,
()
=>
any
>
;
// ... to an object of return types
type
MapFnToProp
<
FunctionObj
extends
FnObj
>
=
{
[
K
in
keyof
FunctionObj
]
:
ReturnType
<
FunctionObj
[
K
]
>
;
};
Podemos utilizar MapFnToProp
para establecer ThisType
para un parámetro de tipo genérico recién añadido llamado Methods
. También añadimos Data
y Methods
a la mezcla. Para pasar el parámetro de tipo genérico Computed
a MapFnToProp
, es necesario restringirlo a FnObj
, la misma restricción del primer parámetro FunctionObj
en MapFnToProp
:
type
Options
<
Data
,
Computed
extends
FnObj
,
Methods
>
=
{
data
(
this
:
{})
?:
Data
;
computed
?:
Computed
&
ThisType
<
Data
>
;
methods
?:
Methods
&
ThisType
<
Data
&
MapFnToProp
<
Computed
>
&
Methods
>
;
};
¡Y ése es el tipo! Tomamos todas las propiedades del tipo genérico y las añadimos a la función de fábrica create
:
declare
function
create
<
Data
,
Computed
extends
FnObj
,
Methods
>
(
options
:
Options
<
Data
,
Computed
,
Methods
>
)
:
any
;
Mediante el uso, se sustituirán todos los parámetros de tipo genérico. Y de la forma en que se escribe Options
, obtenemos todo el autocompletado necesario para asegurarnos de que no nos encontramos con problemas, como se ve en la Figura 4-1.
Este ejemplo muestra maravillosamente cómo se puede utilizar TypeScript para tipificar APIs elaboradas en las que ocurre mucha manipulación de objetos debajo de .1
4.9 Añadir contexto const a parámetros de tipo genérico
Solución
Añade un modificador const
delante de tu parámetro de tipo genérico para mantener losvalores pasadosen contexto const.
Debate
Los marcos de aplicaciones de una sola página (SPA) tienden a reimplementar muchas de las funciones del navegador en JavaScript. Por ejemplo, funciones como la API de Historial hicieron posible anular el comportamiento habitual de navegación, que los marcos SPA utilizan para cambiar de una página a otra sin una recarga real de la página, intercambiando el contenido de la página y cambiando la URL en el navegador.
Imagina que trabajas en un marco SPA minimalista que utiliza un denominado enrutador para navegar entre páginas. Las páginas se definen como componentes, y una interfaz ComponentConstructor
sabe cómo instanciar y renderizar nuevos elementos en tu sitio web:
interface
ComponentConstructor
{
new
()
:
Component
;
}
interface
Component
{
render
()
:
HTMLElement
;
}
El enrutador debe tomar una lista de componentes y rutas asociadas, almacenadas como string
. Al crear un enrutador mediante la función router
, debe devolver un objeto que te permita navigate
la ruta deseada:
type
Route
=
{
path
:
string
;
component
:
ComponentConstructor
;
};
function
router
(
routes
:
Route
[])
{
return
{
navigate
(
path
:
string
)
{
// ...
},
};
}
En este momento no nos preocupa cómo se implementa la navegación real; en su lugar, queremos centrarnos en las tipificaciones de la interfaz de la función.
El enrutador funciona como está previsto; toma una matriz de objetos Route
y devuelve un objeto con una función navigate
, que nos permite activar la navegación de una URL a otra y renderiza el nuevo componente:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/faq"
);
Lo que se ve inmediatamente es que los tipos son demasiado amplios. Si permitimos navegar a todos los string
disponibles, nada nos impide utilizar rutas falsas que no llevan a ninguna parte. Tendríamos que implementar algún tipo de gestión de errores para la información que ya está lista y disponible. Entonces, ¿por qué no utilizarla?
Nuestra primera idea sería sustituir el tipo concreto por un parámetro de tipo genérico. La forma en que TypeScript trata la sustitución genérica es que si tenemos un tipo literal, TypeScript subtipificará en consecuencia. Introducir T
en lugar de Route
y utilizar T["path"]
en lugar de string
se acerca a lo que queremos conseguir:
function
router
<
T
extends
Route
>
(
routes
:
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
// ...
},
};
}
En teoría, esto debería funcionar. Si recordamos lo que hace TypeScript con los tipos literales y primitivos en ese caso, esperaríamos que el valor se redujera al tipo literal:
function
getPath
<
T
extends
string
>
(
route
:
T
)
:
T
{
return
route
;
}
const
path
=
getPath
(
"/"
);
// "/"
Puedes leer más sobre esto en la Receta 4.3. Un detalle importante es que path
en el ejemplo anterior está en un contexto const, porque el valor devuelto es inmutable.
El único problema es que estamos trabajando con objetos y matrices, y TypeScript tiende a ampliar los tipos en objetos y matrices a algo más general para permitir la mutabilidad de los valores. Si miramos un ejemplo similar, pero con un objeto anidado, veremos que TypeScript toma en su lugar el tipo más amplio:
type
Routes
=
{
paths
:
string
[];
};
function
getPaths
<
T
extends
Routes
>
(
routes
:
T
)
:
T
[
"paths"
]
{
return
routes
.
paths
;
}
const
paths
=
getPaths
({
paths
:
[
"/"
,
"/about"
]
});
// string[]
Para los objetos, el contexto const de paths
sólo sirve para el enlace de la variable, no para su contenido. Esto acaba provocando que se pierda parte de la información que necesitamos para escribir correctamente navigate
.
Una forma de sortear esta limitación es aplicar manualmente const context, lo que nos obliga a redefinir el parámetro de entrada para que sea readonly
:
function
router
<
T
extends
Route
>
(
routes
:
readonly
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
history
.
pushState
({},
""
,
path
);
},
};
}
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
]
as
const
);
rtr
.
navigate
(
"/about"
);
Esto funciona, pero también requiere que no olvidemos un detalle muy importante al codificar. Y recordar activamente las soluciones provisionales es siempre una receta para el desastre.
Afortunadamente, TypeScript nos permite solicitar contexto const a los parámetros de tipo genérico. En lugar de aplicarlo al valor, sustituimos el parámetro de tipo genérico por un valor concreto pero en contexto const añadiendo el modificador const
al parámetro de tipo genérico:
function
router
<
const
T
extends
Route
>
(
routes
:
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
// tbd
},
};
}
Entonces podremos utilizar nuestro router tal y como estamos acostumbrados e incluso obtener autocompletado para las posibles rutas:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/about"
);
Mejor aún, obtenemos errores apropiados cuando introducimos algo falso:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/faq"
);
// ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)
Lo bonito: todo está oculto en la API de la función. Lo que esperamos queda más claro, la interfaz nos indica las restricciones, y no tenemos que hacer nada extra al utilizar router
para garantizar la seguridad de tipos.
1 Un agradecimiento especial a los creadores de Type Challenges por este bello ejemplo.
Get Libro de cocina de TypeScript now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.