Capítulo 4. Funciones
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
En el último capítulo cubrimos los fundamentos del sistema de tipos de TypeScript: tipos primitivos, objetos, matrices, tuplas y enums, así como los fundamentos de la inferencia de tipos de TypeScript y cómo funciona la asignabilidad de tipos. Ahora estás preparado para la pièce de résistance (o razón de ser, si eres un programador funcional) de TypeScript: las funciones. Algunos de los temas que trataremos en este capítulo son:
-
Las diferentes formas de declarar e invocar funciones en TypeScript
-
Sobrecarga de firmas
-
Funciones polimórficas
-
Alias de tipos polimórficos
Declarar e invocar funciones
En JavaScript, las funciones son objetos de primera clase. Eso significa que puedes utilizarlas exactamente igual que lo harías en con cualquier otro objeto: asignarlas a variables, pasarlas a otras funciones, devolverlas de funciones, asignarlas a objetos y prototipos, escribir propiedades en ellas, leer esas propiedades de vuelta, etc. Hay muchas cosas que puedes hacer con las funciones en JavaScript, y TypeScript modela todas esas cosas con su rico sistema de tipos.
Este es el aspecto de una función en TypeScript (debería resultarte familiar del último capítulo):
function
add
(
a
:number
,
b
:number
)
{
return
a
+
b
}
Normalmente anotarás explícitamente los parámetros de la función (a
y b
en este ejemplo)-TypeScript siempre inferirá tipos en todo el cuerpo de tu función, pero en la mayoría de los casos no inferirá tipos para tus parámetros, excepto en algunos casos especiales en los que puede inferir tipos a partir del contexto (más sobre esto en "Tipado contextual"). El tipo de retorno se infiere, pero también puedes anotarlo explícitamente si quieres:
function
add
(
a
:number
,
b
:number
)
:
number
{
return
a
+
b
}
Nota
A lo largo de este libro anotaré explícitamente los tipos de retorno cuando te ayude a ti, el lector, a entender lo que hace la función. De lo contrario, omitiré las anotaciones porque TypeScript ya las infiere por nosotros, y ¿para qué querríamos repetir el trabajo?
El último ejemplo utilizó la sintaxis de función con nombre para declarar la función, pero JavaScript y TypeScript admiten al menos cinco formas de hacerlo:
// Named function
function
greet
(
name
:string
)
{
return
'hello '
+
name
}
// Function expression
let
greet2
=
function
(
name
:string
)
{
return
'hello '
+
name
}
// Arrow function expression
let
greet3
=
(
name
:string
)
=>
{
return
'hello '
+
name
}
// Shorthand arrow function expression
let
greet4
=
(
name
:string
)
=>
'hello '
+
name
// Function constructor
let
greet5
=
new
Function
(
'name'
,
'return "hello " + name'
)
Además de los constructores de función (que no deberías utilizar a menos que te persigan las abejas, porque son totalmente inseguros),1 TypeScript admite todas estas sintaxis de forma segura, y todas siguen las mismas reglas en torno a las anotaciones de tipo normalmente obligatorias para los parámetros y opcionales para los tipos de retorno.
Nota
Un rápido repaso a la terminología:
Cuando invocas una función en TypeScript, no necesitas proporcionar ninguna información de tipo adicional: sólo tienes que pasar algunos argumentos, y TypeScript se pondrá a trabajar para comprobar que tus argumentos son compatibles con los tipos de los parámetros de tu función:
add
(
1
,
2
)
// evaluates to 3
greet
(
'Crystal'
)
// evaluates to 'hello Crystal'
Por supuesto, si olvidaste un argumento, o pasaste un argumento de tipo incorrecto, TypeScript será rápido para señalártelo:
add
(
1
)
// Error TS2554: Expected 2 arguments, but got 1.
add
(
1
,
'a'
)
// Error TS2345: Argument of type '"a"' is not assignable
// to parameter of type 'number'.
Parámetros opcionales y por defecto
Al igual que en los tipos objeto y tupla, puedes utilizar ?
para marcar los parámetros como opcionales. Al declarar los parámetros de tu función, los parámetros obligatorios deben ir primero, seguidos de los opcionales:
function
log
(
message
:string
,
userId?
:string
)
{
let
time
=
new
Date
().
toLocaleTimeString
()
console
.
log
(
time
,
message
,
userId
||
'Not signed in'
)
}
log
(
'Page loaded'
)
// Logs "12:38:31 PM Page loaded Not signed in"
log
(
'User signed in'
,
'da763be'
)
// Logs "12:38:31 PM User signed in da763be"
Como en JavaScript, puedes proporcionar valores por defecto para los parámetros opcionales. Desde el punto de vista semántico, es similar a hacer que un parámetro sea opcional, en el sentido de que las personas que llaman ya no tienen que pasarlo (una diferencia es que los parámetros por defecto no tienen que estar al final de la lista de parámetros, mientras que los parámetros opcionales sí).
Por ejemplo, podemos reescribir log
como:
function
log
(
message
:
string
,
userId
=
'Not signed in'
)
{
let
time
=
new
Date
(
)
.
toISOString
(
)
console
.
log
(
time
,
message
,
userId
)
}
log
(
'User clicked on a button'
,
'da763be'
)
log
(
'User signed out'
)
Observa cómo al dar a userId
un valor por defecto, eliminamos su anotación opcional, ?
. Tampoco tenemos que escribirlo. TypeScript es lo suficientemente inteligente como para deducir el tipo del parámetro a partir de su valor por defecto, manteniendo nuestro código conciso y fácil de leer.
Por supuesto, también puedes añadir anotaciones de tipo explícitas a tus parámetros por defecto, del mismo modo que puedes hacerlo para los parámetros sin valores por defecto:
type
Context
=
{
appId?
:
string
userId?
:
string
}
function
log
(
message
:
string
,
context
:
Context
=
{
}
)
{
let
time
=
new
Date
(
)
.
toISOString
(
)
console
.
log
(
time
,
message
,
context
.
userId
)
}
Te encontrarás a menudo utilizando parámetros por defecto en lugar de parámetros opcionales.
Parámetros de reposo
Si una función toma una lista de argumentos, puedes, por supuesto, simplemente pasar la lista como una matriz:
function
sum
(
numbers
:number
[])
:
number
{
return
numbers
.
reduce
((
total
,
n
)
=>
total
+
n
,
0
)
}
sum
([
1
,
2
,
3
])
// evaluates to 6
A veces, puedes optar por una API de función variádica -que acepte un número variable de argumentos - en lugar de una API de función fija que acepte un número fijo de argumentos. Tradicionalmente, esto requería utilizar el objeto mágico de JavaScript arguments
.
arguments
es "mágico" porque tu tiempo de ejecución de JavaScript lo define automáticamente por ti en las funciones, y le asigna la lista de argumentos que pasaste a tu función. Como arguments
sólo es similar a una matriz, y no una verdadera matriz, primero tienes que convertirla en una matriz antes de poder utilizar la función .reduce
:
function
sumVariadic
()
:
number
{
return
Array
.
from
(
arguments
)
.
reduce
((
total
,
n
)
=>
total
+
n
,
0
)
}
sumVariadic
(
1
,
2
,
3
)
// evaluates to 6
Pero hay un gran problema con el uso de arguments
: ¡es totalmente inseguro! Si pasas el ratón por encima de total
o n
en tu editor de texto, verás una salida similar a la que se muestra en la Figura 4-1.
Esto significa que TypeScript deduce que tanto n
como total
son del tipo any
, y silenciosamente lo deja pasar, es decir, hasta que intentas utilizar sumVariadic
:
sumVariadic
(
1
,
2
,
3
)
// Error TS2554: Expected 0 arguments, but got 3.
Como no declaramos que sumVariadic
toma argumentos, desde el punto de vista de TypeScript no toma argumentos, por lo que obtenemos un TypeError
cuando intentamos utilizarlo.
Entonces, ¿cómo podemos escribir con seguridad funciones variádicas?
¡Parámetros de reposo al rescate! En lugar de recurrir a la insegura variable mágica arguments
, podemos utilizar parámetros de reposo para hacer que nuestra función sum
acepte cualquier número de argumentos de forma segura:
function
sumVariadicSafe
(...
numbers
:number
[])
:
number
{
return
numbers
.
reduce
((
total
,
n
)
=>
total
+
n
,
0
)
}
sumVariadicSafe
(
1
,
2
,
3
)
// evaluates to 6
Ya está. Fíjate en que el único cambio entre este sum
variádico y nuestra función sum
original de un solo parámetro es el ...
adicional en la lista de parámetros; no hay que cambiar nada más, y es totalmente segura desde el punto de vista tipográfico.
Una función puede tener como máximo un parámetro de resto, y ese parámetro tiene que ser el último de la lista de parámetros de la función. Por ejemplo, echa un vistazo a la declaración incorporada de TypeScript para console.log
(si no sabes lo que es un interface
, no te preocupes: lo veremos en el Capítulo 5). console.log
toma un message
opcional y cualquier número de argumentos adicionales para registrar:
interface
Console
{
log
(
message?
:any
,
...
optionalParams
:any
[])
:
void
}
llamar, aplicar y vincular
Además de invocar una función con paréntesis ()
, JavaScript admite al menos otras dos formas de llamar a una función. Tomemos como ejemplo add
:
function
add
(
a
:number
,
b
:number
)
:
number
{
return
a
+
b
}
add
(
10
,
20
)
// evaluates to 30
add
.
apply
(
null
,
[
10
,
20
])
// evaluates to 30
add
.
call
(
null
,
10
,
20
)
// evaluates to 30
add
.
bind
(
null
,
10
,
20
)()
// evaluates to 30
apply
vincula un valor a this
dentro de tu función (en este ejemplo, vinculamos this
a null
), y reparte su segundo argumento entre los parámetros de tu función. call
hace lo mismo, pero aplica sus argumentos en orden en lugar de repartirlos.
bind()
es similar, en el sentido de que vincula un this
-argumento y una lista de argumentos a tu función. La diferencia es que bind
no invoca tu función; en su lugar, devuelve una nueva función que luego puedes invocar con ()
, .call
, o .apply
, pasando más argumentos para ligarlos a los parámetros hasta ahora no ligados si quieres.
Escribiendo esto
Si no vienes de JavaScript, puede que te sorprenda saber que en JavaScript la variable this
se define para cada función, no sólo para las funciones que viven como métodos en las clases. this
tiene un valor diferente dependiendo de cómo hayas llamado a tu función, lo que puede hacerla notoriamente frágil y difícil de razonar.
Consejo
Por esta razón, muchos equipos prohíben this
en todas partes excepto en los métodos de clase; para hacer esto también en tu código, activa la regla no-invalid-this
TSLint.
La razón por la que this
es frágil tiene que ver con la forma en que se asigna. La regla general es que this
tomará el valor de lo que esté a la izquierda del punto cuando invoque a un método. Por ejemplo:
let
x
=
{
a() {
return
this
}
}
x
.
a
()
// this is the object x in the body of a()
Pero si en algún momento reasignas a
antes de llamarlo, ¡el resultado cambiará!
let
a
=
x
.
a
a
()
// now, this is undefined in the body of a()
Supongamos que tienes una función de utilidad para dar formato a las fechas que tiene este aspecto:
function
fancyDate() {
return
$
{
this
.
getDate
()}
/
$
{
this
.
getMonth
()}
/
$
{
this
.
getFullYear
()}
}
Diseñaste esta API en tus primeros días como programador (antes de aprender sobre los parámetros de función). Para utilizar fancyDate
, tienes que llamarlo con un Date
ligado a this
:
fancyDate
.
call
(
new
Date
)
// evaluates to "4/14/2005"
Si olvidas vincular un Date
a this
, ¡obtendrás una excepción en tiempo de ejecución!
fancyDate
()
// Uncaught TypeError: this.getDate is not a function
Aunque explorar toda la semántica de this
está fuera del alcance de este libro,2 este comportamiento -que this
depende de la forma en que llames a una función, y no de la forma en que la declares- puede ser, cuando menos, sorprendente.
Afortunadamente, TypeScript te cubre las espaldas. Si tu función utiliza this
, asegúrate de declarar el tipo esperado this
como primer parámetro de tu función (antes de cualquier parámetro adicional), y TypeScript hará cumplir que this
es realmente lo que dices que es en cada sitio de llamada. this
no se trata como otros parámetros: es una palabra reservada cuando se utiliza como parte de la firma de una función:
function
fancyDate
(
this
:
Date
)
{
return
$
{
this
.
getDate
(
)
}
/
$
{
this
.
getMonth
(
)
}
/
$
{
this
.
getFullYear
(
)
}
}
Esto es lo que ocurre cuando llamamos a fancyDate
:
fancyDate
.
call
(
new
Date
)
// evaluates to "6/13/2008"
fancyDate
()
// Error TS2684: The 'this' context of type 'void' is
// not assignable to method's 'this' of type 'Date'.
Tomamos un error en tiempo de ejecución, y, en su lugar, proporcionamos a TypeScript información suficiente para advertir del error en tiempo de compilación.
Indicador TSC: noImplícitoEsto
Para hacer que los tipos this
se anoten siempre explícitamente en las funciones, activa la opción noImplicitThis
en tu tsconfig.json. El modo strict
incluye noImplicitThis
, así que si ya lo tienes activado, puedes seguir adelante.
Ten en cuenta que noImplicitThis
no impone this
-anotaciones para clases, ni para funciones sobre objetos.
Funciones del generador
Las funciones generadoras(generadores para abreviar) son una forma cómoda de, bueno, generar un montón de valores. Proporcionan a , el consumidor del generador, un control preciso sobre el ritmo al que se producen los valores. Como son perezosas -es decir, sólo calculan el siguiente valor cuando un consumidor lo pide- pueden hacer cosas que pueden ser difíciles de hacer de otro modo, como generar listas infinitas.
Funcionan así:
function
*
createFibonacciGenerator() {
let
a
=
0
let
b
=
1
while
(
true
)
{
yield
a
;
[
a
,
b
]
=
[
b
,
a
+
b
]
}
}
let
fibonacciGenerator
=
createFibonacciGenerator
(
)
// IterableIterator<number>
fibonacciGenerator
.
next
(
)
// evaluates to {value: 0, done: false}
fibonacciGenerator
.
next
(
)
// evaluates to {value: 1, done: false}
fibonacciGenerator
.
next
(
)
// evaluates to {value: 1, done: false}
fibonacciGenerator
.
next
(
)
// evaluates to {value: 2, done: false}
fibonacciGenerator
.
next
(
)
// evaluates to {value: 3, done: false}
fibonacciGenerator
.
next
(
)
// evaluates to {value: 5, done: false}
El asterisco (
*
) delante del nombre de una función hace que esa función sea un generador. Llamar a un generador devuelve un iterador iterable.Nuestro generador puede generar valores para siempre.
Los generadores utilizan la palabra clave
yield
para, bueno, producir valores. Cuando un consumidor pide el siguiente valor del generador (por ejemplo, llamando anext
),yield
devuelve un resultado al consumidor y detiene la ejecución hasta que el consumidor pida el siguiente valor. De este modo, el buclewhile(true)
no provoca inmediatamente que el programa se ejecute eternamente y se bloquee.Para calcular el siguiente número de Fibonacci, reasignamos
a
ab
yb
aa + b
en un solo paso.
Llamamos a createFibonacciGenerator
, y eso nos devolvió un IterableIterator
. Cada vez que llamamos a next
, el iterador calcula el siguiente número de Fibonacci y yield
s nos lo devuelve. Observa cómo TypeScript es capaz de inferir el tipo de nuestro iterador a partir del tipo del valor que yield
ed.
También puedes anotar explícitamente un generador, envolviendo el tipo que produce en un IterableIterator
:
function
*
createNumbers
()
:
IterableIterator
<
number
>
{
let
n
=
0
while
(
1
)
{
yield
n
++
}
}
let
numbers
=
createNumbers
()
numbers
.
next
()
// evaluates to {value: 0, done: false}
numbers
.
next
()
// evaluates to {value: 1, done: false}
numbers
.
next
()
// evaluates to {value: 2, done: false}
No profundizaremos en los generadores en este libro: son un gran tema, y como este libro trata sobre TypeScript, no quiero desviarme con características de JavaScript. El resumen es que son una característica genial del lenguaje JavaScript que TypeScript también soporta. Para saber más sobre los generadores, visita su página en MDN.
Iteradores
Los iteradores son la otra cara de los generadores: mientras que los generadores son una forma de producir un flujo de valores, los iteradores son una forma de consumir esos valores. La terminología puede resultar bastante confusa, así que empecemos con un par de definiciones.
Cuando creas un generador (por ejemplo, llamando a createFibonacciGenerator
), obtienes de vuelta un valor que es a la vez un iterable y un iterador-un iterable iterable- porquedefine tanto una propiedad Symbol.iterator
como un método next
.
Puedes definir manualmente un iterador o un iterable creando un objeto (o una clase) que implemente Symbol.iterator
o next
, respectivamente. Por ejemplo, definamos un iterador que devuelva los números del 1 al 10:
let
numbers
=
{
*
[
Symbol
.
iterator
]()
{
for
(
let
n
=
1
;
n
<=
10
;
n
++
)
{
yield
n
}
}
}
Si escribes ese iterador en tu editor de código y pasas el ratón por encima, verás lo que TypeScript infiere como su tipo(Figura 4-2).
En otras palabras, numbers
es un iterador, y llamar a la función generadora numbers[Symbol.iterator]()
devuelve un iterador iterable.
No sólo puedes definir tus propios iteradores, sino que puedes utilizar los iteradores incorporados de JavaScript para tipos de colecciones comunes -Array
, Map
, Set
, String
,3 etc., para hacer cosas como
// Iterate over an iterator with for-of
for
(
let
a
of
numbers
)
{
// 1, 2, 3, etc.
}
// Spread an iterator
let
allNumbers
=
[...
numbers
]
// number[]
// Destructure an iterator
let
[
one
,
two
,
...
rest
]
=
numbers
// [number, number, number[]]
De nuevo, no profundizaremos en los iteradores en este libro. Puedes leer más sobre iteradores e iteradores asíncronos en MDN.
Indicador TSC: downlevelIteration
Si estás compilando tu TypeScript con una versión de JavaScript anterior a ES2015
, puedes activar los iteradores personalizados con la opción downlevelIteration
en tu tsconfig.json.
Puede que quieras mantener downlevelIteration
desactivado si tu aplicación es especialmente sensible al tamaño del paquete: se necesita mucho código para hacer funcionar los iteradores personalizados en entornos antiguos. Por ejemplo, el ejemplo anterior de numbers
genera casi 1 KB de código (comprimido con gzip).
Firmas de llamada
Hasta ahora hemos aprendido a tipificar los parámetros y los tipos de retorno de las funciones. Ahora, cambiemos de marcha y hablemos de cómo podemos expresar los tipos completos de las propias funciones.
Volvamos a visitar sum
desde el principio de este capítulo. Como recordatorio, tiene este aspecto:
function
sum
(
a
:number
,
b
:number
)
:
number
{
return
a
+
b
}
¿Cuál es el tipo de sum
? Bien, sum
es una función, por lo que su tipo es:
Function
El tipo Function
, como habrás adivinado, no es el que quieres utilizar la mayoría de las veces. Al igual que object
describe todos los objetos, Function
es un tipo comodín para todas las funciones, y no te dice nada sobre la función concreta que tipifica.
¿De qué otra forma podemos escribir sum
? sum
es una función que toma dos numbers
y devuelve un number
. En TypeScript podemos expresar su tipo como:
(
a
:number
,
b
:number
)
=>
number
Esta es la sintaxis de TypeScript para el tipo de una función, o firma de llamada (también llamada firma de tipo). Verás que se parece mucho a una función flecha, ¡es intencionado! Cuando pases funciones como argumentos, o las devuelvas desde otras funciones, ésta es la sintaxis que utilizarás para escribirlas.
Nota
Los nombres de los parámetros a
y b
sólo sirven como documentación, y no afectan a la asignabilidad de una función con ese tipo.
Las firmas de llamada a función sólo contienen código a nivel de tipo, es decir, sólo tipos, sin valores. Esto significa que las firmas de llamada a función pueden expresar tipos de parámetros, tipos this
(ver "Tipificación de esto"), tipos de retorno, tipos de resto y tipos opcionales, y no pueden expresar valores por defecto (ya que un valor por defecto es un valor, no un tipo). Y como no tienen un cuerpo que TypeScript pueda deducir, las firmas de llamada requieren anotaciones explícitas de tipo de retorno.
Repasemos algunos de los ejemplos de funciones que hemos visto hasta ahora en este capítulo, y extraigamos sus tipos en firmas de llamada independientes que vincularemos a alias de tipos:
// function greet(name: string)
type
Greet
=
(
name
:string
)
=>
string
// function log(message: string, userId?: string)
type
Log
=
(
message
:string
,
userId?
:string
)
=>
void
// function sumVariadicSafe(...numbers: number[]): number
type
SumVariadicSafe
=
(...
numbers
:number
[])
=>
number
¿Lo has entendido? Las firmas de llamada de las funciones se parecen mucho a sus implementaciones. Esto es intencionado, y es una elección de diseño del lenguaje que hace que las firmas de llamada sean más fáciles de razonar.
Hagamos más concreta la relación entre las firmas de llamada y sus implementaciones. Si tienes una firma de llamada, ¿cómo puedes declarar una función que implemente esa firma? Simplemente combina la firma de llamada con una expresión de función que la implemente. Por ejemplo, reescribamos Log
para utilizar su nueva y brillante firma:
type
Log
=
(
message
:
string
,
userId?
:
string
)
=
>
void
let
log
:
Log
=
(
message
,
userId
=
'Not signed in'
)
=
>
{
let
time
=
new
Date
(
)
.
toISOString
(
)
console
.
log
(
time
,
message
,
userId
)
}
Declaramos una expresión de función
log
, y la escribimos explícitamente como tipoLog
.No necesitamos anotar nuestros parámetros dos veces. Dado que
message
ya está anotado comostring
como parte de la definición deLog
, no necesitamos escribirlo de nuevo aquí. En su lugar, dejamos que TypeScript lo deduzca por nosotros deLog
.Añadimos un valor por defecto para
userId
, ya que capturamos el tipo deuserId
en nuestra firma paraLog
, pero no pudimos capturar el valor por defecto como parte deLog
porqueLog
es un tipo y no puede contener valores.No necesitamos volver a anotar nuestro tipo de retorno, puesto que ya lo declaramos como
void
en nuestro tipoLog
.
Tipificación contextual
Observa que el último ejemplo ha sido el primero que hemos visto en el que no hemos tenido que anotar explícitamente los tipos de nuestros parámetros de función. Como ya hemos declarado que log
es del tipo Log
, TypeScript es capaz de inferir por el contexto que message
tiene que ser del tipo string
. Esta es una potente característica de la inferencia de tipos de TypeScript llamada tipado contextual.
Anteriormente en este capítulo, hemos mencionado otro lugar en el que aparece la tipificación contextual: las funciones de llamada de retorno.5
Declaremos una función times
que llame a su llamada de retorno f
un cierto número de veces n
, pasando el índice actual a f
cada vez:
function
times
(
f
:
(
index
:number
)
=>
void
,
n
:number
)
{
for
(
let
i
=
0
;
i
<
n
;
i
++
)
{
f
(
i
)
}
}
Cuando llamas a times
, no tienes que anotar explícitamente la función que pasas a times
si declaras esa función en línea:
times
(
n
=>
console
.
log
(
n
),
4
)
TypeScript deduce del contexto que n
es un number
-declaramos que el argumento de f
' index
es un number
en la firma de times
'y TypeScript es lo suficientemente inteligente como para deducir que n
es ese argumento, por lo que debe ser un number
.
Ten en cuenta que si no declaráramos f
inline, TypeScript no habría podido deducir su tipo:
function
f
(
n
)
{
// Error TS7006: Parameter 'n' implicitly has an 'any' type.
console
.
log
(
n
)
}
times
(
f
,
4
)
Tipos de funciones sobrecargadas
La sintaxis del tipo de función que utilizamos en la última sección -type Fn = (...) => ...
- es una firma de llamada abreviada. Podemos escribirla de forma más explícita. Tomando de nuevo el ejemplo de Log
:
// Shorthand call signature
type
Log
=
(
message
:string
,
userId?
:string
)
=>
void
// Full call signature
type
Log
=
{
(
message
:string
,
userId?
:string
)
:
void
}
Los dos son completamente equivalentes en todos los sentidos, y sólo difieren en la sintaxis.
¿Alguna vez querrías utilizar una firma de llamada completa en lugar de la abreviada? Para casos sencillos como nuestra función Log
, deberías preferir la abreviatura; pero para funciones más complicadas, hay algunos buenos casos de uso de las firmas completas.
La primera de ellas es sobrecargar un tipo de función. Pero antes, ¿qué significa sobrecargar una función?
En la mayoría de los lenguajes de programación, una vez que declaras una función que toma un conjunto de parámetros y devuelve un tipo determinado, puedes llamar a esa función exactamente con ese conjunto de parámetros y siempre obtendrás el mismo tipo de devolución. En JavaScript no es así. Como JavaScript es un lenguaje tan dinámico, es habitual que haya varias formas de llamar a una función determinada; no sólo eso, sino que a veces el tipo de salida dependerá del tipo de entrada de un argumento.
TypeScript modela este dinamismo -declaraciones de función sobrecargadas, y el tipo de salida de una función dependiendo de su tipo de entrada- con su sistema de tipos estático. Puede que demos por sentada esta característica del lenguaje, ¡pero es una característica realmente avanzada para un sistema de tipos!
Puedes utilizar firmas de funciones sobrecargadas para diseñar API realmente expresivas. Por ejemplo, diseñemos una API para reservar unas vacaciones: la llamaremos Reserve
. Empecemos por esbozar sus tipos (esta vez con una firma de tipo completa):
type
Reserve
=
{
(
from
:Date
,
to
:Date
,
destination
:string
)
:
Reservation
}
A continuación, vamos a crear una implementación para Reserve
:
let
reserve
:Reserve
=
(
from
,
to
,
destination
)
=>
{
// ...
}
Así, un usuario que quiera reservar un viaje a Bali tiene que llamar a nuestra API reserve
con una fecha from
, una fecha to
y "Bali"
como destino.
Podríamos adaptar nuestra API para que también admita viajes de ida:
type
Reserve
=
{
(
from
:Date
,
to
:Date
,
destination
:string
)
:
Reservation
(
from
:Date
,
destination
:string
)
:
Reservation
}
Observarás que cuando intentes ejecutar este código, TypeScript te dará un error en el punto donde implementas Reserve
(véase la Figura 4-3).
Esto se debe a la forma en que funciona la sobrecarga de firmas de llamada en TypeScript. Si declaras un conjunto de firmas de sobrecarga para una función f
, desde el punto de vista de quien la llama, el tipo de f
es la unión de esas firmas de sobrecarga. Pero desde el punto de vista de la implementación de f
, tiene que haber un único tipo combinado que pueda implementarse realmente. Tienes que declarar manualmente esta firma de llamada combinada al implementar f
-no se deducirá por ti. Para nuestro ejemplo Reserve
, podemos actualizar nuestra función reserve
de la siguiente manera:
type
Reserve
=
{
(
from
:
Date
,
to
:
Date
,
destination
:
string
)
:
Reservation
(
from
:
Date
,
destination
:
string
)
:
Reservation
}
let
reserve
:
Reserve
=
(
from
:
Date
,
toOrDestination
:
Date
|
string
,
destination?
:
string
)
=
>
{
// ...
}
Declaramos dos firmas de funciones sobrecargadas.
La firma de la implementación es el resultado de combinar manualmente las dos firmas de sobrecarga (en otras palabras, hemos calculado
Signature1 | Signature2
a mano). Ten en cuenta que la firma combinada no es visible para las funciones que llaman areserve
; desde el punto de vista del consumidor, la firma deReserve
sí lo es:type
Reserve
=
{
(
from
:Date
,
to
:Date
,
destination
:string
)
:
Reservation
(
from
:Date
,
destination
:string
)
:
Reservation
}
En particular, esto no incluye la firma combinada que hemos creado:
// Wrong!
type
Reserve
=
{
(
from
:Date
,
to
:Date
,
destination
:string
)
:
Reservation
(
from
:Date
,
destination
:string
)
:
Reservation
(
from
:Date
,
toOrDestination
:Date
|
string
,
destination?
:string
)
:
Reservation
}
Dado que reserve
puede ser llamado de dos maneras, cuando implementes reserve
tendrás que demostrar a TypeScript que has comprobado cómo se ha llamado:6
let
reserve
:
Reserve
=
(
from
:
Date
,
toOrDestination
:
Date
|
string
,
destination
?:
string
)
=>
{
if
(
toOrDestination
instanceof
Date
&&
destination
!==
undefined
)
{
// Book a one-way trip
}
else
if
(
typeof
toOrDestination
===
'string'
)
{
// Book a round trip
}
}
Las sobrecargas surgen de forma natural en las API DOM de los navegadores. La API DOM createElement
, por ejemplo, se utiliza para crear un nuevo elemento HTML. Toma una cadena correspondiente a una etiqueta HTML y devuelve un nuevo elemento HTML del tipo de esa etiqueta. TypeScript viene con tipos incorporados para cada elemento HTML. Entre ellos están:
-
HTMLAnchorElement
para<a>
elementos -
HTMLCanvasElement
para<canvas>
elementos -
HTMLTableElement
para<table>
elementos
Las firmas de llamada sobrecargadas son una forma natural de modelar cómo funciona createElement
. Piensa en cómo podrías escribir createElement
(¡intenta responder a esto por ti mismo antes de seguir leyendo!).
La respuesta:
type
CreateElement
=
{
(
tag
:
'a'
)
:
HTMLAnchorElement
(
tag
:
'canvas'
)
:
HTMLCanvasElement
(
tag
:
'table'
)
:
HTMLTableElement
(
tag
:
string
)
:
HTMLElement
}
let
createElement
:
CreateElement
=
(
tag
:
string
)
:
HTMLElement
=
>
{
// ...
}
Sobrecargamos el tipo del parámetro, comparándolo con tipos literales de cadena.
Añadimos un caso trampa: si el usuario pasa un nombre de etiqueta personalizado, o un nombre de etiqueta experimental de última generación que aún no se ha abierto camino en las declaraciones de tipos incorporadas de TypeScript, devolvemos un
HTMLElement
genérico. Dado que TypeScript resuelve las sobrecargas en el orden en que fueron declaradas,7 cuando llames acreateElement
con una cadena que no tenga definida una sobrecarga específica (por ejemplo,createElement('foo')
), TypeScript volverá aHTMLElement
.Para tipificar el parámetro de la implementación, combinamos todos los tipos que pueda tener ese parámetro en las firmas de sobrecarga de
createElement
, lo que da como resultado'a' | 'canvas' | 'table' | string
. Como los tres tipos de literales de cadena son todos subtipos destring
, el tipo se reduce astring
.
Nota
En todos los ejemplos de esta sección hemos sobrecargado expresiones de función. ¿Pero qué pasa si queremos sobrecargar una declaración de función? Como siempre, TypeScript te cubre las espaldas, con una sintaxis equivalente para las declaraciones de funciones. Reescribamos nuestras sobrecargas en createElement
:
function
createElement
(
tag
:
'a'
)
:
HTMLAnchorElement
function
createElement
(
tag
:
'canvas'
)
:
HTMLCanvasElement
function
createElement
(
tag
:
'table'
)
:
HTMLTableElement
function
createElement
(
tag
:string
)
:
HTMLElement
{
// ...
}
La sintaxis que utilices depende de ti y del tipo de función que estés sobrecargando (expresión de función o declaración de función).
Las firmas de tipo completas no se limitan a sobrecargar la forma de llamar a una función. También puedes utilizarlas para modelar propiedades en las funciones. Como las funciones de JavaScript no son más que objetos invocables, puedes asignarles propiedades para hacer cosas como:
function
warnUser
(
warning
)
{
if
(
warnUser
.
wasCalled
)
{
return
}
warnUser
.
wasCalled
=
true
alert
(
warning
)
}
warnUser
.
wasCalled
=
false
Es decir, mostramos al usuario una advertencia, y no mostramos una advertencia más de una vez. Utilicemos TypeScript para escribir la firma completa de warnUser
:
type
WarnUser
=
{
(
warning
:string
)
:
void
wasCalled
:boolean
}
A continuación, podemos reescribir warnUser
como una expresión de función que implemente esa firma:
let
warnUser
:WarnUser
=
(
warning
:string
)
=>
{
if
(
warnUser
.
wasCalled
)
{
return
}
warnUser
.
wasCalled
=
true
alert
(
warning
)
}
warnUser
.
wasCalled
=
false
Ten en cuenta que TypeScript es lo suficientemente inteligente como para darse cuenta de que, aunque no asignamos wasCalled
a warnUser
cuando declaramos la función warnUser
, sí le asignamos wasCalled
justo después.
Polimorfismo
Hasta ahora en este libro, hemos estado hablando de los cómos y los porqués de los tipos concretos, y de las funciones que utilizan tipos concretos. ¿Qué es un tipo concreto? Resulta que todos los tipos que hemos visto hasta ahora son concretos:
-
boolean
-
string
-
Date[]
-
{a: number} | {b: string}
-
(numbers: number[]) => number
Los tipos concretos son útiles cuando sabes exactamente qué tipo esperas y quieres verificar que ese tipo se ha pasado realmente. Pero a veces, no sabes de antemano qué tipo esperar, ¡y no quieres restringir el comportamiento de tu función a un tipo concreto!
Como ejemplo de lo que quiero decir, implementemos filter
. Utiliza filter
para iterar sobre una matriz y refinarla; en JavaScript, podría tener este aspecto:
function
filter
(
array
,
f
)
{
let
result
=
[]
for
(
let
i
=
0
;
i
<
array
.
length
;
i
++
)
{
let
item
=
array
[
i
]
if
(
f
(
item
))
{
result
.
push
(
item
)
}
}
return
result
}
filter
([
1
,
2
,
3
,
4
],
_
=>
_
<
3
)
// evaluates to [1, 2]
Empecemos sacando la firma de tipos completa de filter
y añadiendo algunos marcadores unknown
s para los tipos:
type
Filter
=
{
(
array
:unknown
,
f
:unknown
)
=>
unknown
[]
}
Ahora, intentemos rellenar los tipos con, digamos, number
:
type
Filter
=
{
(
array
:
number
[
]
,
f
:
(
item
:
number
)
=
>
boolean
)
:
number
[
]
}
Escribir los elementos de la matriz como number
funciona bien para este ejemplo, pero filter
está pensada para ser una función genérica: puedes filtrar matrices de números, cadenas, objetos, otras matrices, lo que sea. La firma que escribimos funciona para matrices de números, pero no para matrices de otros tipos de elementos. Intentemos utilizar una sobrecarga para ampliarla y que funcione también con matrices de cadenas:
type
Filter
=
{
(
array
:
number
[
]
,
f
:
(
item
:
number
)
=
>
boolean
)
:
number
[
]
(
array
:
string
[
]
,
f
:
(
item
:
string
)
=
>
boolean
)
:
string
[
]
}
Hasta aquí todo bien (aunque puede resultar engorroso escribir una sobrecarga para cada tipo). ¿Qué pasa con las matrices de objetos?
type
Filter
=
{
(
array
:
number
[
]
,
f
:
(
item
:
number
)
=
>
boolean
)
:
number
[
]
(
array
:
string
[
]
,
f
:
(
item
:
string
)
=
>
boolean
)
:
string
[
]
(
array
:
object
[
]
,
f
:
(
item
:
object
)
=
>
boolean
)
:
object
[
]
}
Esto puede parecer correcto a primera vista, pero intentemos utilizarlo para ver dónde se rompe. Si implementas una función filter
con esa firma (es decir, filter: Filter
), e intentas utilizarla, obtendrás:
let
names
=
[
{
firstName
:
'beth'
},
{
firstName
:
'caitlyn'
},
{
firstName
:
'xin'
}
]
let
result
=
filter
(
names
,
_
=>
_
.
firstName
.
startsWith
(
'b'
)
)
// Error TS2339: Property 'firstName' does not exist on type 'object'.
result
[
0
].
firstName
// Error TS2339: Property 'firstName' does not exist
// on type 'object'.
Llegados a este punto, debería tener sentido por qué TypeScript lanza este error. Le dijimos a TypeScript que podíamos pasar una matriz de números, cadenas u objetos a filter
. Pasamos una matriz de objetos, pero recuerda que object
no te dice nada sobre la forma del objeto. Así que cada vez que intentamos acceder a una propiedad de un objeto de la matriz, TypeScript lanza un error, porque no le hemos dicho qué forma específica tiene el objeto.
¿Qué hacer?
Si vienes de un lenguaje que admite tipos genéricos, a estas alturas estarás poniendo los ojos en blanco y gritando: "¡PARA ESO ESTÁN LOS GENÉRICOS!". La buena noticia es que has dado en el clavo (la mala es que acabas de despertar al hijo del vecino con tus gritos).
Por si no has trabajado antes con tipos genéricos, primero los definiré y luego daré un ejemplo con nuestra función filter
.
Volviendo a nuestro ejemplo de filter
, éste es el aspecto de su tipo cuando lo reescribimos con un parámetro de tipo genérico T
:
type
Filter
=
{
<
T
>
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
:
T
[
]
}
Lo que hemos hecho aquí es decir "Esta función filter
utiliza un parámetro de tipo genérico T
; no sabemos de antemano cuál será este tipo, así que si TypeScript puede deducir cuál es cada vez que llamemos a filter
, sería estupendo". TypeScript infiere T
a partir del tipo que pasamos por array
. Una vez que TypeScript infiere qué es T
para una llamada dada a filter
, sustituye ese tipo por cada T
que ve. T
es como un tipo de marcador de posición, que el verificador de tipos rellenará a partir del contexto; parametriza el tipo de Filter
, que es por lo que lo llamamos parámetro de tipo genérico.
Nota
Como es un trabalenguas decir "parámetro de tipo genérico" cada vez, la gente suele acortarlo a sólo "tipo genérico", o simplemente "genérico". Utilizaré los términos indistintamente a lo largo de este libro.
Los graciosos corchetes angulares de , <>
, son la forma de declarar parámetros de tipos genéricos (piensa en ellos como en la palabra clave type
, pero para tipos genéricos); el lugar donde coloques los corchetes angulares delimita el ámbito de los genéricos (sólo puedes colocarlos en unos pocos lugares), y TypeScript se asegura de que, dentro de su ámbito, todas las instancias de los parámetros de tipos genéricos se vinculen finalmente a los mismos tipos concretos. Debido a dónde están los corchetes angulares en este ejemplo, TypeScript vinculará tipos concretos a nuestro genérico T
cuando llamemos a filter
. Y decidirá qué tipo concreto ligar a T
dependiendo de con qué hayamos llamado a filter
. Puedes declarar tantos parámetros de tipo genérico separados por comas como quieras entre un par de corchetes angulares.
Nota
T
es sólo un nombre de tipo, y podríamos haber utilizado cualquier otro nombre en su lugar: A
, Zebra
, o l33t
. Por convención, la gente utiliza nombres de una sola letra en mayúsculas, empezando por la letra T
y continuando con U
, V
, W
, y así sucesivamente en función del número de genéricos que necesiten.
Si declaras muchos genéricos seguidos o los utilizas de forma complicada, considera la posibilidad de desviarte de esta convención y utilizar en su lugar nombres más descriptivos como Value
o WidgetType
.
Algunas personas prefieren empezar en A
en lugar de T
. Diferentes comunidades de lenguajes de programación prefieren uno u otro, dependiendo de su herencia: los usuarios de lenguajes funcionales prefieren A
, B
, C
, etc. por su parecido con las letras griegas α, β y γ que puedes encontrar en las pruebas matemáticas; los usuarios de lenguajes orientados a objetos tienden a usar T
para "Type". TypeScript, aunque admite ambos estilos de programación, utiliza esta última convención.
Como el parámetro de una función se vuelve a enlazar cada vez que llamas a esa función, así cada llamada a filter
obtiene su propio enlace para T
:
type
Filter
=
{
<
T
>
(
array
:T
[],
f
:
(
item
:T
)
=>
boolean
)
:
T
[]
}
let
filter
:Filter
=
(
array
,
f
)
=>
// ...
// (a) T is bound to number
filter
([
1
,
2
,
3
],
_
=>
_
>
2
)
// (b) T is bound to string
filter
([
'a'
,
'b'
],
_
=>
_
!==
'b'
)
// (c) T is bound to {firstName: string}
let
names
=
[
{
firstName
:
'beth'
},
{
firstName
:
'caitlyn'
},
{
firstName
:
'xin'
}
]
filter
(
names
,
_
=>
_
.
firstName
.
startsWith
(
'b'
))
TypeScript infiere estos enlaces genéricos a partir de los tipos de los argumentos que pasamos. Veamos cómo TypeScript vincula T
para (a):
-
Por la firma de tipo de
filter
, TypeScript sabe quearray
es una matriz de elementos de algún tipoT
. -
TypeScript se da cuenta de que hemos pasado la matriz
[1, 2, 3]
, por lo queT
debe sernumber
. -
Siempre que TypeScript ve un
T
, lo sustituye por el tiponumber
. Así, el parámetrof: (item: T) => boolean
se convierte enf: (item: number) => boolean
, y el tipo de retornoT[]
se convierte ennumber[]
. -
TypeScript comprueba que todos los tipos satisfacen la asignabilidad, y que la función que pasamos como
f
es asignable a su firma recién inferida.
Los genéricos son una forma poderosa de decir lo que hace tu función de una forma más general de lo que permiten los tipos concretos. La forma de pensar en los genéricos es como restricciones. Del mismo modo que anotar un parámetro de función como n: number
limita el valor del parámetro n
al tipo number
, utilizar un genérico T
limita el tipo de cualquier tipo que vincules a T
para que sea del mismo tipo en todos los lugares en los que aparezca T
.
Consejo
Los tipos genéricos también pueden utilizarse en alias de tipos, clases e interfaces: los utilizaremos abundantemente a lo largo de este libro. Los presentaré en su contexto a medida que tratemos más temas.
Utiliza genéricos siempre que puedas. Te ayudarán a mantener tu código general, reutilizable y conciso.
¿Cuándo están obligados los genéricos?
El lugar donde declares un tipo genérico no sólo determina el alcance del tipo, sino que también dicta cuándo TypeScript vinculará un tipo concreto a tu genérico. Del último ejemplo:
type
Filter
=
{
<
T
>
(
array
:T
[],
f
:
(
item
:T
)
=>
boolean
)
:
T
[]
}
let
filter
:Filter
=
(
array
,
f
)
=>
// ...
Dado que declaramos <T>
como parte de una firma de llamada (justo antes del paréntesis de apertura de la firma, ()
, TypeScript vinculará un tipo concreto a T
cuando realmente llamemos a una función del tipo Filter
.
Si en lugar de ello hubiéramos asignado T
al alias de tipo Filter
, TypeScript nos habría exigido vincular explícitamente un tipo cuando utilizáramos Filter
:
type
Filter
<
T
>
=
{
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
:
T
[
]
}
let
filter
:
Filter
=
(
array
,
f
)
=
>
// Error TS2314: Generic type 'Filter'
// ...
// requires 1 type argument(s).
type
OtherFilter
=
Filter
// Error TS2314: Generic type 'Filter'
// requires 1 type argument(s).
let
filter
:
Filter
<
number
>
=
(
array
,
f
)
=
>
// ...
type
StringFilter
=
Filter
<
string
>
let
stringFilter
:
StringFilter
=
(
array
,
f
)
=
>
// ...
En general, TypeScript vinculará tipos concretos a tu genérico cuando utilices el genérico: en el caso de las funciones, cuando las llames; en el caso de las clases, cuando las instales (más información en "Polimorfismo"); y en el caso de los alias de tipos y las interfaces (véase "Interfaces"), cuando las utilices o implementes.
¿Dónde puedes declarar genéricos?
Para cada una de las formas de TypeScript de declarar una firma de llamada, hay una forma de añadirle un tipo genérico:
type
Filter
=
{
<
T
>
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
:
T
[
]
}
let
filter
:
Filter
=
// ...
type
Filter
<
T
>
=
{
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
:
T
[
]
}
let
filter
:
Filter
<
number
>
=
// ...
type
Filter
=
<
T
>
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
=
>
T
[
]
let
filter
:
Filter
=
// ...
type
Filter
<
T
>
=
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
=
>
T
[
]
let
filter
:
Filter
<
string
>
=
// ...
function
filter
<
T
>
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
boolean
)
:
T
[
]
{
// ...
}
Una firma de llamada completa, con
T
delimitada a una firma individual. Dado queT
está limitado a una única firma, TypeScript vincularáT
en esta firma a un tipo concreto cuando llames a una función de tipofilter
. Cada llamada afilter
tendrá su propio enlace paraT
.Una firma de llamada completa, con
T
con alcance a todas las firmas. ComoT
se declara como parte del tipo deFilter
(y no como parte del tipo de una firma específica), TypeScript vincularáT
cuando declares una función del tipoFilter
.Como pero con una firma de llamada abreviada en lugar de una completa.
Como pero con una firma de llamada abreviada en lugar de una completa.
Una firma de llamada a una función con nombre, con
T
en el ámbito de la firma. TypeScript vinculará un tipo concreto aT
cuando llames afilter
, y cada llamada afilter
obtendrá su propia vinculación paraT
.
Como segundo ejemplo, escribamos una función map
. map
es bastante similar a filter
, pero en lugar de eliminar elementos de una matriz, transforma cada elemento con una función de mapeo. Empezaremos esbozando la implementación:
function
map
(
array
:unknown
[],
f
:
(
item
:unknown
)
=>
unknown
)
:
unknown
[]
{
let
result
=
[]
for
(
let
i
=
0
;
i
<
array
.
length
;
i
++
)
{
result
[
i
]
=
f
(
array
[
i
])
}
return
result
}
Antes de continuar, intenta pensar cómo harías que map
fuera genérico, sustituyendo cada unknown
por algún tipo. ¿Cuántos genéricos necesitas? ¿Cómo declaras tus genéricos y los aplicas a la función map
? ¿Cuáles deberían ser los tipos de array
, f
, y el valor de retorno?
¿Preparado? Si no has intentado hacerlo tú primero, te animo a que lo intentes. Tú puedes hacerlo. ¡De verdad!
Vale, basta de dar la lata. Aquí tienes la respuesta:
function
map
<
T
,
U
>
(
array
:
T
[
]
,
f
:
(
item
:
T
)
=
>
U
)
:
U
[
]
{
let
result
=
[
]
for
(
let
i
=
0
;
i
<
array
.
length
;
i
++
)
{
result
[
i
]
=
f
(
array
[
i
]
)
}
return
result
}
Necesitamos exactamente dos tipos genéricos: T
para el tipo de los miembros de la matriz que entran, y U
para el tipo de los miembros de la matriz que salen. Pasamos una matriz de T
s, y una función de mapeo que toma un T
y lo mapea a un U
. Finalmente, devolvemos una matriz de U
s.
Inferencia genérica de tipos
En la mayoría de los casos, TypeScript hace un gran trabajo infiriendo tipos genéricos por ti. Cuando llamas a la función map
que escribimos antes, TypeScript infiere que T
es string
y U
es boolean
:
function
map
<
T
,
U
>
(
array
:T
[],
f
:
(
item
:T
)
=>
U
)
:
U
[]
{
// ...
}
map
(
[
'a'
,
'b'
,
'c'
],
// An array of T
_
=>
_
===
'a'
// A function that returns a U
)
Sin embargo, también puedes anotar explícitamente tus genéricos. Las anotaciones explícitas para los genéricos son de todo o nada; o anotas cada tipo genérico necesario, o ninguno:
map
<
string
,
boolean
>
(
[
'a'
,
'b'
,
'c'
],
_
=>
_
===
'a'
)
map
<
string
>
(
// Error TS2558: Expected 2 type arguments, but got 1.
[
'a'
,
'b'
,
'c'
],
_
=>
_
===
'a'
)
TypeScript comprobará que cada tipo genérico inferido es asignable a su correspondiente genérico vinculado explícitamente; si no es asignable, obtendrás un error:
// OK, because boolean is assignable to boolean | string
map
<
string
,
boolean
|
string
>
(
[
'a'
,
'b'
,
'c'
],
_
=>
_
===
'a'
)
map
<
string
,
number
>
(
[
'a'
,
'b'
,
'c'
],
_
=>
_
===
'a'
// Error TS2322: Type 'boolean' is not assignable
)
// to type 'number'.
Como TypeScript infiere tipos concretos para tus genéricos a partir de los argumentos que pasas a tu función genérica, a veces te encontrarás con un caso como éste:
let
promise
=
new
Promise
(
resolve
=>
resolve
(
45
)
)
promise
.
then
(
result
=>
// Inferred as {}
result
*
4
// Error TS2362: The left-hand side of an arithmetic operation must
)
// be of type 'any', 'number', 'bigint', or an enum type.
¿Por qué? ¿Por qué TypeScript dedujo que result
era {}
? Porque no le dimos suficiente información con la que trabajar: como TypeScript sólo utiliza los tipos de los argumentos de una función genérica para inferir el tipo de un genérico, por defecto convirtió T
en {}
.
Para solucionarlo, tenemos que anotar explícitamente el parámetro de tipo genérico de Promise
:
let
promise
=
new
Promise
<
number
>
(
resolve
=>
resolve
(
45
)
)
promise
.
then
(
result
=>
// number
result
*
4
)
Alias de tipos genéricos
Ya hemos hablado de los alias de tipos genéricos en nuestro ejemplo Filter
del capítulo anterior. Y si recuerdas los tipos Array
y ReadonlyArray
del capítulo anterior (véase "Matrices y tuplas de sólo lectura"), ¡también son alias de tipos genéricos! Profundicemos en el uso de los genéricos en los alias de tipos con un breve ejemplo.
Definamos un tipo MyEvent
que describa un evento DOM, como un click
o un mousedown
:
type
MyEvent
<
T
>
=
{
target
:T
type
:string
}
Ten en cuenta que éste es el único lugar válido para declarar un tipo genérico en un alias de tipo: justo después del nombre del alias de tipo, antes de su asignación (=
).
MyEvent
's target
apunta al elemento sobre el que se ha producido el evento: un <button />
, un <div />
, etc. Por ejemplo, podrías describir un evento de botón de la siguiente manera:
type
ButtonEvent
=
MyEvent
<
HTMLButtonElement
>
Cuando utilizas un tipo genérico como MyEvent
, tienes que vincular explícitamente sus parámetros de tipo cuando utilices el tipo; no se deducirán por ti:
let
myEvent
:Event
<
HTMLButtonElement
|
null
>
=
{
target
:document.querySelector
(
'#myButton'
),
type
:
'click'
}
Puedes utilizar MyEvent
para construir otro tipo, por ejemplo, TimedEvent
. Cuando el genérico T
en TimedEvent
TypeScript también lo vinculará a MyEvent
:
type
TimedEvent
<
T
>
=
{
event
:MyEvent
<
T
>
from
:Date
to
:Date
}
También puedes utilizar un alias de tipo genérico en la firma de una función. Cuando TypeScript vincula un tipo a T
, también lo vinculará a MyEvent
por ti:
function
triggerEvent
<
T
>
(
event
:MyEvent
<
T
>
)
:
void
{
// ...
}
triggerEvent
({
// T is Element | null
target
:document.querySelector
(
'#myButton'
),
type
:
'mouseover'
})
Vamos a ver lo que ocurre aquí paso a paso:
-
Llamamos a
triggerEvent
con un objeto. -
TypeScript ve que, según la firma de nuestra función, el argumento que pasamos tiene que ser del tipo
MyEvent<T>
. También se da cuenta de que hemos definidoMyEvent<T>
como{target: T, type: string}
. -
TypeScript se da cuenta de que el campo
target
del objeto que pasamos esdocument.querySelector('#myButton')
. Eso implica queT
debe ser del tipo que seadocument.querySelector('#myButton')
:Element | null
. Así queT
está ahora vinculado aElement | null
. -
TypeScript sustituye cada vez que aparece
T
porElement | null
. -
TypeScript comprueba que todos nuestros tipos satisfacen la asignabilidad. Lo hacen, por lo que nuestro código comprueba la tipabilidad.
Polimorfismo Limitado
Nota
En esta sección voy a utilizar un árbol binario como ejemplo. Si no has trabajado antes con árboles binarios, no te preocupes. Para nuestro propósito, lo básico es
-
Un árbol binario es un tipo de estructura de datos.
-
Un árbol binario está formado por nodos.
-
Un nodo contiene un valor y puede apuntar a un máximo de dos nodos hijos.
-
Un nodo puede ser de dos tipos: un nodo hoja (significa que no tiene hijos) o un nodo interior (significa que tiene al menos un hijo).
A veces, decir "esta cosa es de algún tipo genérico T
y esa cosa tiene que tener el mismo tipo T
" no es suficiente. A veces también quieres decir "el tipo U
debe ser al menos T
." A esto lo llamamos poner un límite superior a U
.
¿Por qué querríamos hacer esto? Digamos que estamos implementando un árbol binario, y tenemos tres tipos de nodos:
-
Regular
TreeNode
s -
LeafNode
s, que sonTreeNode
s que no tienen hijos -
InnerNode
s, que sonTreeNode
s que sí tienen hijos
Empecemos declarando tipos para nuestros nodos:
type
TreeNode
=
{
value
:string
}
type
LeafNode
=
TreeNode
&
{
isLeaf
:true
}
type
InnerNode
=
TreeNode
&
{
children
:
[
TreeNode
]
|
[
TreeNode
,
TreeNode
]
}
Lo que estamos diciendo es: un TreeNode
es un objeto con una única propiedad, value
. El tipo LeafNode
tipo tiene todas las propiedades que tiene TreeNode
, más una propiedad isLeaf
que siempre es true
. InnerNode
también tiene todas las propiedades de TreeNode
, más una propiedad children
que apunta a uno o dos hijos.
A continuación, vamos a escribir una función mapNode
que tome un TreeNode
y mapee su valor, devolviendo un nuevo TreeNode
. Queremos llegar a una función mapNode
que podamos utilizar así:
let
a
:TreeNode
=
{
value
:
'a'
}
let
b
:LeafNode
=
{
value
:
'b'
,
isLeaf
:true
}
let
c
:InnerNode
=
{
value
:
'c'
,
children
:
[
b
]}
let
a1
=
mapNode
(
a
,
_
=>
_
.
toUpperCase
())
// TreeNode
let
b1
=
mapNode
(
b
,
_
=>
_
.
toUpperCase
())
// LeafNode
let
c1
=
mapNode
(
c
,
_
=>
_
.
toUpperCase
())
// InnerNode
Ahora haz una pausa y piensa cómo podrías escribir una función mapNode
que tomara un subtipo de TreeNode
y devolviera ese mismo subtipo. Si pasas un LeafNode
debería devolver un LeafNode
, un InnerNode
debería devolver un InnerNode
, y un TreeNode
debería devolver un TreeNode
. Piensa cómo lo harías antes de continuar. ¿Es posible?
Aquí tienes la respuesta:
function
mapNode
<
T
extends
TreeNode
>
(
node
:
T
,
f
:
(
value
:
string
)
=
>
string
)
:
T
{
return
{
.
.
.
node
,
value
:
f
(
node
.
value
)
}
}
mapNode
es una función que define un único parámetro de tipo genérico,T
.T
tiene un límite superior deTreeNode
. Es decir,T
puede ser o bien unTreeNode
, o bien un subtipo deTreeNode
.mapNode
toma dos parámetros, el primero de los cuales es unnode
de tipoT
. Porque en dijimosnode extends TreeNode
, si pasáramos algo que no fuera unTreeNode
-por ejemplo, un objeto vacío{}
,null
, o una matriz deTreeNode
s- eso sería un garabato rojo instantáneo.node
tiene que ser unTreeNode
o un subtipo deTreeNode
.mapNode
devuelve un valor del tipoT
. Recuerda queT
puede ser unTreeNode
, o cualquier subtipo deTreeNode
.
¿Por qué tuvimos que declarar T
de esa manera?
-
Si hubiéramos escrito
T
sólo comoT
(omitiendoextends TreeNode
),mapNode
habría arrojado un error de compilación, porque no se puede leernode.value
con seguridad en unnode
no delimitado de tipoT
(¿y si el usuario introduce un número?). -
Si hubiéramos omitido por completo
T
y declaradomapNode
como(node: TreeNode, f: (value: string) => string) => TreeNode
, habríamos perdido información tras mapear un nodo:a1
b1
yc1
serían simplementeTreeNode
s.
Al decir que T extends TreeNode
, conseguimos conservar el tipo específico del nodo de entrada (TreeNode
, LeafNode
, o InnerNode
), incluso después de mapearlo.
Polimorfismo limitado con múltiples restricciones
En el último ejemplo, ponemos una única restricción de tipo en T
: T
tiene que ser al menos a TreeNode
. Pero, ¿y si quieres varias restricciones de tipo?
Sólo tienes que ampliar la intersección (&
) de esas restricciones:
type
HasSides
=
{
numberOfSides
:
number
}
type
SidesHaveLength
=
{
sideLength
:
number
}
function
logPerimeter
<
Shape
extends
HasSides
&
SidesHaveLength
>
(
s
:
Shape
)
:
Shape
{
console
.
log
(
s
.
numberOfSides
*
s
.
sideLength
)
return
s
}
type
Square
=
HasSides
&
SidesHaveLength
let
square
:
Square
=
{
numberOfSides
:
4
,
sideLength
:
3
}
logPerimeter
(
square
)
// Square, logs "12"
logPerimeter
es una función que toma un único argumentos
de tipoShape
.Shape
es un tipo genérico que amplía tanto el tipoHasSides
como el tipoSidesHaveLength
tipo En otras palabras, unShape
tiene que tener al menos lados con longitudes.logPerimeter
devuelve un valor exactamente del mismo tipo que le diste.
Utilizar el polimorfismo limitado para modelar la aridad
Otro lugar donde te encontrarás utilizando el polimorfismo acotado es para modelar funciones variádicas (funciones que toman cualquier número de argumentos). Por ejemplo, implementemos nuestra propia versión de la función incorporada de JavaScript call
(como recordatorio, call
es una función que toma una función y un número variable de argumentos, y aplica esos argumentos a la función).8 La definiremos y utilizaremos así, usando unknown
para los tipos que rellenaremos más adelante:
function
call
(
f
:
(...
args
:unknown
[])
=>
unknown
,
...
args
:unknown
[]
)
:
unknown
{
return
f
(...
args
)
}
function
fill
(
length
:number
,
value
:string
)
:
string
[]
{
return
Array
.
from
({
length
},
()
=>
value
)
}
call
(
fill
,
10
,
'a'
)
// evaluates to an array of 10 'a's
Ahora vamos a rellenar el unknown
s. Las restricciones que queremos expresar son:
-
f
debe ser una función que tome algún conjunto de argumentosT
, y devuelva algún tipoR
. No sabemos de antemano cuántos argumentos tendrá. -
call
tomaf
, junto con el mismo conjunto de argumentosT
que toma la propiaf
. De nuevo, no sabemos exactamente cuántos argumentos esperar de antemano. -
call
devuelve el mismo tipoR
quef
.
Necesitaremos dos parámetros de tipo: T
, que es una matriz de argumentos, y R
, que es un valor de retorno arbitrario. Rellenemos los tipos:
function
call
<
T
extends
unknown
[
]
,
R
>
(
f
:
(
.
.
.
args
:
T
)
=
>
R
,
.
.
.
args
:
T
)
:
R
{
return
f
(
.
.
.
args
)
}
¿Cómo funciona exactamente? Veámoslo paso a paso:
-
call
es una función variádica (como recordatorio, una función variádica es una función que acepta cualquier número de argumentos) que tiene dos parámetros de tipo:T
yR
.T
es un subtipo deunknown[]
; es decir,T
es una matriz o tupla de cualquier tipo. -
call
'es una funciónf
.f
también es variádica, y sus argumentos comparten tipo conargs
: sea cual sea el tipo deargs
, los argumentos def
tienen exactamente el mismo tipo. -
Además de una función
f
,call
tiene un número variable de parámetros adicionales...args
.args
es un parámetro de resto, es decir, un parámetro que describe un número variable de argumentos.args
El tipo de la función esT
, yT
tiene que ser un tipo array (de hecho, si olvidáramos decir queT
extiende un tipo array, TypeScript nos lanzaría un garabato), así que TypeScript inferirá un tipo tupla paraT
basándose en los argumentos específicos que pasamos paraargs
. -
call
devuelve un valor del tipoR
(R
está ligado al tipo que devuelvaf
).
Ahora, cuando llamemos a call
, TypeScript sabrá exactamente cuál es el tipo de retorno, y se quejará cuando pasemos un número incorrecto de argumentos:
let
a
=
call
(
fill
,
10
,
'a'
)
// string[]
let
b
=
call
(
fill
,
10
)
// Error TS2554: Expected 3 arguments; got 2.
let
c
=
call
(
fill
,
10
,
'a'
,
'z'
)
// Error TS2554: Expected 3 arguments; got 4.
Utilizamos una técnica similar para aprovechar la forma en que TypeScript infiere los tipos de tupla para los parámetros de reposo, con el fin de mejorar la inferencia de tipos para tuplas de en "Mejora de la inferencia de tipos para tuplas".
Tipos genéricos por defecto
Igual que puedes dar valores por defecto a los parámetros de las funciones, puedes dar tipos por defecto a los parámetros de los tipos genéricos. Por ejemplo, volvamos al tipo MyEvent
de "Alias de tipos genéricos". Como recordatorio, utilizamos el tipo para modelar eventos DOM, y tiene el siguiente aspecto:
type
MyEvent
<
T
>
=
{
target
:T
type
:string
}
Para crear un nuevo evento, tenemos que vincular explícitamente un tipo genérico a MyEvent
, que representa el tipo de elemento HTML sobre el que se ha enviado el evento:
let
buttonEvent
:MyEvent
<
HTMLButtonElement
>
=
{
target
:myButton
,
type
:string
}
Como comodidad para cuando no conozcamos de antemano el tipo de elemento concreto al que se vinculará MyEvent
, podemos añadir un valor por defecto para el genérico de MyEvent
:
type
MyEvent
<
T
=
HTMLElement
>
=
{
target
:
T
type
:
string
}
También podemos aprovechar esta oportunidad para aplicar lo que hemos aprendido en las últimas secciones y añadir un vínculo a T
, para asegurarnos de que T
es un elemento HTML:
type
MyEvent
<
T
extends
HTMLElement
=
HTMLElement
>
=
{
target
:
T
type
:
string
}
Ahora podemos crear fácilmente un evento que no sea específico de un tipo de elemento HTML concreto, y no tenemos que vincular manualmente MyEvent
s T
a HTMLElement
cuando creamos el evento:
let
myEvent
:MyEvent
=
{
target
:myElement
,
type
:string
}
Ten en cuenta que, al igual que los parámetros opcionales en las funciones, los tipos genéricos con valores por defecto tienen que aparecer después de los tipos genéricos sin valores por defecto:
// Good
type
MyEvent2
<
Type
extends
string
,
Target
extends
HTMLElement
=
HTMLElement
,
>
=
{
target
:Target
type
:Type
}
// Bad
type
MyEvent3
<
Target
extends
HTMLElement
=
HTMLElement
,
Type
extends
string
// Error TS2706: Required type parameters may
>
=
{
// not follow optional type parameters.
target
:Target
type
:Type
}
Desarrollo basado en tipos
Un sistema de tipos potente conlleva un gran poder. Cuando escribas en TypeScript, a menudo te encontrarás "dirigiendo con los tipos". Esto, por supuesto, se refiere al desarrollo dirigido por tipos.
El objetivo de los sistemas de tipos estáticos es restringir los tipos de valores que puede contener una expresión. Cuanto más expresivo sea el sistema de tipos, más te dirá sobre el valor contenido en esa expresión. Cuando aplicas un sistema de tipos expresivo a una función, la firma de tipos de la función puede acabar diciéndote la mayor parte de lo que necesitas saber sobre esa función.
Veamos la firma de tipo de la función map
de antes:
function
map
<
T
,
U
>
(
array
:T
[],
f
:
(
item
:T
)
=>
U
)
:
U
[]
{
// ...
}
Sólo con mirar esa firma -incluso si nunca has visto map
antes- deberías tener alguna intuición de lo que hace map
: toma una matriz de T
y una función que mapea de un T
a un U
, y devuelve una matriz de U
. ¡Fíjate en que no has tenido que ver la implementación de la función para saberlo!9
Cuando escribas un programa TypeScript, empieza definiendo las firmas de tipo de tus funciones -en otras palabras, empieza con los tipos- y completalas implementaciones más tarde. Al esbozar primero tu programa a nivel de tipos, te aseguras de que todo tiene sentido a alto nivel antes de pasar a las implementaciones.
Te habrás dado cuenta de que hasta ahora hemos estado haciendo lo contrario: empezar con la implementación y luego deducir los tipos. Ahora que ya sabes escribir y tipar funciones en TypeScript, vamos a cambiar de modo: primero esbozaremos los tipos y luego completaremos los detalles.
Resumen
En este capítulo hemos hablado de cómo declarar y llamar a funciones, cómo tipar parámetros y cómo expresar características comunes de las funciones JavaScript como parámetros por defecto, parámetros de reposo, funciones generadoras e iteradores en TypeScript. Hemos hablado de la diferencia entre las firmas de llamada y las implementaciones de las funciones, de la tipificación contextual y de las distintas formas de sobrecargar funciones. Por último, cubrimos en profundidad el polimorfismo para funciones y alias de tipos: por qué es útil, cómo y dónde declarar tipos genéricos, cómo TypeScript infiere tipos genéricos, y cómo declarar y añadir límites y valores por defecto a tus genéricos. Terminamos con una breve nota sobre el desarrollo basado en tipos: qué es y cómo puedes utilizar tus nuevos conocimientos sobre tipos de funciones para llevarlo a cabo.
Ejercicios
-
¿Qué partes de la firma de tipo de una función infiere TypeScript: los parámetros, el tipo de retorno o ambos?
-
¿Es seguro el objeto
arguments
de JavaScript? Si no es así, ¿qué puedes utilizar en su lugar? -
Quieres poder reservar unas vacaciones que empiecen inmediatamente. Actualiza la función sobrecargada
reserve
de antes en este capítulo ("Tipos de funciones sobrecargadas") con una tercera firma de llamada que sólo tome un destino, sin una fecha de inicio explícita. Actualiza la implementación dereserve
para que admita esta nueva firma sobrecargada. -
[Difícil] Actualiza nuestra implementación de
call
de antes en el capítulo ("Uso del polimorfismo acotado para modelar la aridad") para que sólo funcione con funciones cuyo segundo argumento sea unstring
. Para todas las demás funciones, tu implementación debería fallar en tiempo de compilación. -
Implementa una pequeña biblioteca de aserciones a prueba de tipos,
is
. Empieza por esbozar tus tipos. Cuando hayas terminado, deberías poder utilizarla así:
// Compare a string and a string
is
(
'string'
,
'otherstring'
)
// false
// Compare a boolean and a boolean
is
(
true
,
false
)
// false
// Compare a number and a number
is
(
42
,
42
)
// true
// Comparing two different types should give a compile-time error
is
(
10
,
'foo'
)
// Error TS2345: Argument of type '"foo"' is not assignable
// to parameter of type 'number'.
// [Hard] I should be able to pass any number of arguments
is
([
1
],
[
1
,
2
],
[
1
,
2
,
3
])
// false
1 ¿Por qué son inseguros? Si introduces ese último ejemplo en tu editor de código, verás que su tipo es Function
. ¿Qué es este tipo Function
? Es un objeto que se puede llamar (ya sabes, poniendo ()
después de él) y que tiene todos los métodos prototipo de Function.prototype
. Pero sus parámetros y su tipo de retorno no están tipados, por lo que puedes llamar a la función con los argumentos que quieras, y TypeScript se quedará de brazos cruzados, observando cómo haces algo que debería ser ilegal en cualquier ciudad en la que vivas.
2 Para profundizar en this
, consulta la serie You Don't Know JS de Kyle Simpson, de O'Reilly.
3 En particular, Object
y Number
no son iteradores.
4 Las excepciones a esta regla general son las enumeraciones y los espacios de nombres. Los enums generan tanto un tipo como un valor, y los espacios de nombres existen sólo a nivel de valor. Consulta el Apéndice C para obtener una referencia completa.
5 Si no has oído antes el término "devolución de llamada", no es más que una función que pasas como argumento a otra función.
6 Para saber más, salta a "Refinamiento".
7 Mostly-TypeScript eleva las sobrecargas literales por encima de las no literales, antes de resolverlas en orden. Sin embargo, es posible que no quieras depender de esta función, ya que puede hacer que tus sobrecargas sean difíciles de entender para otros ingenieros que no estén familiarizados con este comportamiento.
8 Para simplificar un poco nuestra implementación, vamos a diseñar nuestra función call
para que no tenga en cuenta this
.
9 Hay algunos lenguajes de programación (como el lenguaje Idris, similar a Haskell) que tienen incorporados solucionadores de restricciones con capacidad para implementar automáticamente cuerpos de funciones por ti a partir de las firmas que escribas.
Get Programación 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.