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:

  • Un parámetro es un dato que una función necesita para ejecutarse, declarado como parte de la declaración de una función. También se llama parámetro formal.

  • Un argumento es un dato que pasas a una función al invocarla. También se llama parámetro real.

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.

prts 0401
Figura 4-1. los argumentos no son seguros

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.

Indicador TSC: strictBindCallApply

Para utilizar con seguridad .call, .apply, y .bind en tu código, asegúrate de activar la opción strictBindCallApply en tu tsconfig.json (se activa automáticamente si ya has activado el modo strict ).

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() { 1
  let a = 0
  let b = 1
  while (true) { 2
    yield a; 3
    [a, b] = [b, a + b] 4
  }
}

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}
1

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.

2

Nuestro generador puede generar valores para siempre.

3

Los generadores utilizan la palabra clave yield para, bueno, producir valores. Cuando un consumidor pide el siguiente valor del generador (por ejemplo, llamando a next), yield devuelve un resultado al consumidor y detiene la ejecución hasta que el consumidor pida el siguiente valor. De este modo, el bucle while(true) no provoca inmediatamente que el programa se ejecute eternamente y se bloquee.

4

Para calcular el siguiente número de Fibonacci, reasignamos a a b y b a a + 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 yields nos lo devuelve. Observa cómo TypeScript es capaz de inferir el tipo de nuestro iterador a partir del tipo del valor que yielded.

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).

prts 0402
Figura 4-2. Definir manualmente un iterador

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 = ( 1
  message, 2
  userId = 'Not signed in' 3
) => { 4
  let time = new Date().toISOString()
  console.log(time, message, userId)
}
1

Declaramos una expresión de función log, y la escribimos explícitamente como tipo Log.

2

No necesitamos anotar nuestros parámetros dos veces. Dado que message ya está anotado como string como parte de la definición de Log, no necesitamos escribirlo de nuevo aquí. En su lugar, dejamos que TypeScript lo deduzca por nosotros de Log.

3

Añadimos un valor por defecto para userId, ya que capturamos el tipo de userIden nuestra firma para Log, pero no pudimos capturar el valor por defecto como parte de Log porque Log es un tipo y no puede contener valores.

4

No necesitamos volver a anotar nuestro tipo de retorno, puesto que ya lo declaramos como void en nuestro tipo Log.

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).

prts 0403
Figura 4-3. TypeError cuando falta una firma de sobrecarga combinada

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 fes 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
} 1

let reserve: Reserve = (
  from: Date,
  toOrDestination: Date | string,
  destination?: string
) => { 2
  // ...
}
1

Declaramos dos firmas de funciones sobrecargadas.

2

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 a reserve; desde el punto de vista del consumidor, la firma de Reservesí 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 1
  (tag: 'canvas'): HTMLCanvasElement
  (tag: 'table'): HTMLTableElement
  (tag: string): HTMLElement 2
}

let createElement: CreateElement = (tag: string): HTMLElement => { 3
  // ...
}
1

Sobrecargamos el tipo del parámetro, comparándolo con tipos literales de cadena.

2

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 a createElement con una cadena que no tenga definida una sobrecarga específica (por ejemplo, createElement('foo')), TypeScript volverá a HTMLElement.

3

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 de string, el tipo se reduce a string.

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 filtery añadiendo algunos marcadores unknowns 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):

  1. Por la firma de tipo de filter, TypeScript sabe que array es una matriz de elementos de algún tipo T.

  2. TypeScript se da cuenta de que hemos pasado la matriz [1, 2, 3], por lo que T debe ser number.

  3. Siempre que TypeScript ve un T, lo sustituye por el tipo number. Así, el parámetro f: (item: T) => boolean se convierte en f: (item: number) => boolean, y el tipo de retorno T[] se convierte en number[].

  4. 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 = { 1
  <T>(array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter = // ...

type Filter<T> = { 2
  (array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter<number> = // ...

type Filter = <T>(array: T[], f: (item: T) => boolean) => T[] 3
let filter: Filter = // ...

type Filter<T> = (array: T[], f: (item: T) => boolean) => T[] 4
let filter: Filter<string> = // ...

function filter<T>(array: T[], f: (item: T) => boolean): T[] { 5
  // ...
}
1

Una firma de llamada completa, con T delimitada a una firma individual. Dado que T está limitado a una única firma, TypeScript vinculará T en esta firma a un tipo concreto cuando llames a una función de tipo filter. Cada llamada a filter tendrá su propio enlace para T.

2

Una firma de llamada completa, con T con alcance a todas las firmas. Como T se declara como parte del tipo de Filter(y no como parte del tipo de una firma específica), TypeScript vinculará T cuando declares una función del tipo Filter.

3

Como 1pero con una firma de llamada abreviada en lugar de una completa.

4

Como 2pero con una firma de llamada abreviada en lugar de una completa.

5

Una firma de llamada a una función con nombre, con T en el ámbito de la firma. TypeScript vinculará un tipo concreto a T cuando llames a filter, y cada llamada a filter obtendrá su propia vinculación para T.

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 Ts, y una función de mapeo que toma un T y lo mapea a un U. Finalmente, devolvemos una matriz de Us.

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:

  1. Llamamos a triggerEvent con un objeto.

  2. 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 definido MyEvent<T> como {target: T, type: string}.

  3. TypeScript se da cuenta de que el campo target del objeto que pasamos es document.querySelector('#myButton'). Eso implica que T debe ser del tipo que sea document.querySelector('#myButton'): Element | null. Así que T está ahora vinculado a Element | null.

  4. TypeScript sustituye cada vez que aparece T por Element | null.

  5. 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:

  1. Regular TreeNodes

  2. LeafNodes, que son TreeNodes que no tienen hijos

  3. InnerNodes, que son TreeNodes 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>( 1
  node: T, 2
  f: (value: string) => string
): T { 3
  return {
    ...node,
    value: f(node.value)
  }
}
1

mapNode es una función que define un único parámetro de tipo genérico, T. T tiene un límite superior de TreeNode. Es decir, T puede ser o bien un TreeNode, o bien un subtipo de TreeNode.

2

mapNode toma dos parámetros, el primero de los cuales es un node de tipo T. Porque en 1 dijimos node extends TreeNode, si pasáramos algo que no fuera un TreeNode-por ejemplo, un objeto vacío {}, null, o una matriz de TreeNodes- eso sería un garabato rojo instantáneo. node tiene que ser un TreeNode o un subtipo de TreeNode.

3

mapNode devuelve un valor del tipo T. Recuerda que T puede ser un TreeNode, o cualquier subtipo de TreeNode.

¿Por qué tuvimos que declarar T de esa manera?

  • Si hubiéramos escrito T sólo como T (omitiendo extends TreeNode), mapNode habría arrojado un error de compilación, porque no se puede leer node.value con seguridad en un node no delimitado de tipo T (¿y si el usuario introduce un número?).

  • Si hubiéramos omitido por completo T y declarado mapNode como (node: TreeNode, f: (value: string) => string) => TreeNode, habríamos perdido información tras mapear un nodo: a1 b1 y c1 serían simplemente TreeNodes.

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< 1
  Shape extends HasSides & SidesHaveLength 2
>(s: Shape): Shape { 3
  console.log(s.numberOfSides * s.sideLength)
  return s
}

type Square = HasSides & SidesHaveLength
let square: Square = {numberOfSides: 4, sideLength: 3}
logPerimeter(square) // Square, logs "12"
1

logPerimeter es una función que toma un único argumento s de tipo Shape.

2

Shape es un tipo genérico que amplía tanto el tipo HasSides como el tipo SidesHaveLength tipo En otras palabras, un Shape tiene que tener al menos lados con longitudes.

3

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 unknowns. Las restricciones que queremos expresar son:

  • f debe ser una función que tome algún conjunto de argumentos T, y devuelva algún tipo R. No sabemos de antemano cuántos argumentos tendrá.

  • call toma f, junto con el mismo conjunto de argumentos T que toma la propia f. De nuevo, no sabemos exactamente cuántos argumentos esperar de antemano.

  • call devuelve el mismo tipo R que f.

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>( 1
  f: (...args: T) => R, 2
  ...args: T 3
): R { 4
  return f(...args)
}

¿Cómo funciona exactamente? Veámoslo paso a paso:

1

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 y R. T es un subtipo de unknown[]; es decir, T es una matriz o tupla de cualquier tipo.

2

call'es una función f. f también es variádica, y sus argumentos comparten tipo con args: sea cual sea el tipo de args, los argumentos de f tienen exactamente el mismo tipo.

3

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. argsEl tipo de la función es T, y T tiene que ser un tipo array (de hecho, si olvidáramos decir que T extiende un tipo array, TypeScript nos lanzaría un garabato), así que TypeScript inferirá un tipo tupla para T basándose en los argumentos específicos que pasamos para args.

4

call devuelve un valor del tipo R (R está ligado al tipo que devuelva f ).

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 MyEvents 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

  1. ¿Qué partes de la firma de tipo de una función infiere TypeScript: los parámetros, el tipo de retorno o ambos?

  2. ¿Es seguro el objeto arguments de JavaScript? Si no es así, ¿qué puedes utilizar en su lugar?

  3. 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 de reservepara que admita esta nueva firma sobrecargada.

  4. [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 un string. Para todas las demás funciones, tu implementación debería fallar en tiempo de compilación.

  5. 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.