TypeScript para Programadores Funcionales

TypeScript comenzó su vida como un intento de llevar los tipos tradicionales orientados a objetos a JavaScript para que los programadores de Microsoft pudieran llevar programas tradicionales orientados a objetos a la web. A medida que se ha desarrollado, el sistema de tipos de TypeScript ha evolucionado para modelar el código escrito por programadores nativos de JavaScript. El sistema resultante es potente, interesante y complejo.

Esta introducción está diseñada para programadores funcionales de Haskell o ML que quieran aprender TypeScript. Describe cómo el sistema de tipos de TypeScript difiere del sistema de tipos de Haskell. También describe características únicas del sistema de tipos de TypeScript que surgen de su modelado del código JavaScript.

Esta introducción no cubre la programación orientada a objetos. En la práctica, los programas orientados a objetos en TypeScript son similares a los de otros lenguajes populares con características de POO.

Prerrequisitos

En esta introducción, asumo que sabes lo siguiente:

  • Cómo programar en JavaScript, las partes buenas.
  • Sintaxis de tipos de un lenguaje descendiente de C.

Si necesitas aprender las partes buenas de JavaScript, lee JavaScript: The Good Parts. Es posible que puedas omitir el libro si sabes cómo escribir programas en un lenguaje de ámbito léxico y llamada por valor con mucha mutabilidad y no mucho más. R4RS Scheme es un buen ejemplo.

The C++ Programming Language es un buen lugar para aprender sobre la sintaxis de tipos estilo C. A diferencia de C++, TypeScript usa tipos posfijos, así: x: string en lugar de string x.

Conceptos que no están en Haskell

Tipos incorporados

JavaScript define 8 tipos incorporados:

Tipo Explicación
Number un número de punto flotante de doble precisión IEEE 754.
String una cadena UTF-16 inmutable.
BigInt enteros en formato de precisión arbitraria.
Boolean true y false.
Symbol un valor único usualmente usado como clave.
Null equivalente al tipo unitario (unit).
Undefined también equivalente al tipo unitario (unit).
Object similar a los registros (records).

Consulta la página de MDN para más detalles.

TypeScript tiene tipos primitivos correspondientes para los tipos incorporados:

  • number
  • string
  • bigint
  • boolean
  • symbol
  • null
  • undefined
  • object

Otros tipos importantes de TypeScript

Tipo Explicación
unknown el tipo superior (top type).
never el tipo inferior (bottom type).
literal objeto ej. { propiedad: Tipo }
void para funciones sin valor de retorno documentado
T[] arrays mutables, también escrito Array<T>
[T, T] tuplas, que son de longitud fija pero mutables
(t: T) => U funciones

Notas:

  1. La sintaxis de función incluye nombres de parámetros. ¡Esto es bastante difícil de acostumbrarse!

    ts
    let fst: (a: any, b: any) => any = (a, b) => a;
    // o más precisamente:
    let fst: <T, U>(a: T, b: U) => T = (a, b) => a;
  2. La sintaxis de tipo literal objeto refleja de cerca la sintaxis de valor literal objeto:

    ts
    let o: { n: number; xs: object[] } = { n: 1, xs: [] };
  3. [T, T] es un subtipo de T[]. Esto es diferente de Haskell, donde las tuplas no están relacionadas con las listas.

Tipos “en caja” (Boxed types)

JavaScript tiene equivalentes “en caja” (boxed) de tipos primitivos que contienen los métodos que los programadores asocian con esos tipos. TypeScript refleja esto con, por ejemplo, la diferencia entre el tipo primitivo number y el tipo “en caja” Number. Los tipos “en caja” rara vez son necesarios, ya que sus métodos devuelven primitivos.

ts
(1).toExponential();
// equivalente a
Number.prototype.toExponential.call(1);

Nota que llamar a un método en un literal numérico requiere que esté entre paréntesis para ayudar al analizador sintáctico (parser).

Tipado Gradual

TypeScript usa el tipo any siempre que no puede determinar cuál debería ser el tipo de una expresión. Comparado con Dynamic, llamar a any un tipo es una exageración. Simplemente desactiva el verificador de tipos dondequiera que aparezca. Por ejemplo, puedes insertar cualquier valor en un any[] sin marcar el valor de ninguna manera:

ts
// con "noImplicitAny": false en tsconfig.json, anys: any[]
const anys = [];
anys.push(1);
anys.push("oh no");
anys.push({ anything: "goes" });
Try

Y puedes usar una expresión de tipo any en cualquier lugar:

ts
anys.map(anys[1]); // oh no, "oh no" no es una función

any también es contagioso — si inicializas una variable con una expresión de tipo any, la variable también tiene tipo any.

ts
let sepsis = anys[0] + anys[1]; // esto podría significar cualquier cosa

Para obtener un error cuando TypeScript produce un any, usa "noImplicitAny": true, o "strict": true en tsconfig.json.

Tipado Estructural

El tipado estructural es un concepto familiar para la mayoría de los programadores funcionales, aunque Haskell y la mayoría de los ML no son tipados estructuralmente. Su forma básica es bastante simple:

ts
// @strict: false
let o = { x: "hi", extra: 1 }; // ok
let o2: { x: string } = o; // ok

Aquí, el literal objeto { x: "hi", extra: 1 } tiene un tipo literal coincidente { x: string, extra: number }. Ese tipo es asignable a { x: string } ya que tiene todas las propiedades requeridas y esas propiedades tienen tipos asignables. La propiedad extra no previene la asignación, solo lo convierte en un subtipo de { x: string }.

Los tipos con nombre simplemente dan un nombre a un tipo; para propósitos de asignabilidad no hay diferencia entre el alias de tipo One y el tipo de interfaz Two a continuación. Ambos tienen una propiedad p: string. (Los alias de tipo se comportan de manera diferente a las interfaces con respecto a las definiciones recursivas y los parámetros de tipo, sin embargo.)

ts
type One = { p: string };
interface Two {
p: string;
}
class Three {
p = "Hello";
}
 
let x: One = { p: "hi" };
let two: Two = x;
two = new Three();
Try

Uniones

En TypeScript, los tipos unión no están etiquetados. En otras palabras, no son uniones discriminadas como data en Haskell. Sin embargo, a menudo puedes discriminar tipos en una unión usando etiquetas incorporadas u otras propiedades.

ts
function start(
arg: string | string[] | (() => string) | { s: string }
): string {
// esto es súper común en JavaScript
if (typeof arg === "string") {
return commonCase(arg);
} else if (Array.isArray(arg)) {
return arg.map(commonCase).join(",");
} else if (typeof arg === "function") {
return commonCase(arg());
} else {
return commonCase(arg.s);
}
 
function commonCase(s: string): string {
// finalmente, solo convierte una cadena a otra cadena
return s;
}
}
Try

string, Array y Function tienen predicados de tipo incorporados, dejando convenientemente el tipo objeto para la rama else. Es posible, sin embargo, generar uniones que son difíciles de diferenciar en tiempo de ejecución. Para código nuevo, es mejor construir solo uniones discriminadas.

Los siguientes tipos tienen predicados incorporados:

Tipo Predicado
string typeof s === "string"
number typeof n === "number"
bigint typeof m === "bigint"
boolean typeof b === "boolean"
symbol typeof g === "symbol"
undefined typeof undefined === "undefined"
function typeof f === "function"
array Array.isArray(a)
object typeof o === "object"

Nota que las funciones y los arrays son objetos en tiempo de ejecución, pero tienen sus propios predicados.

Intersecciones

Además de las uniones, TypeScript también tiene intersecciones:

ts
type Combined = { a: number } & { b: string };
type Conflicting = { a: number } & { a: string };
Try

Combined tiene dos propiedades, a y b, tal como si hubieran sido escritas como un tipo literal objeto. La intersección y la unión son recursivas en caso de conflictos, por lo que Conflicting.a: number & string.

Tipos Unitarios

Los tipos unitarios son subtipos de tipos primitivos que contienen exactamente un valor primitivo. Por ejemplo, la cadena "foo" tiene el tipo "foo". Dado que JavaScript no tiene enumeraciones incorporadas, es común usar un conjunto de cadenas bien conocidas en su lugar. Las uniones de tipos literales de cadena permiten a TypeScript tipar este patrón:

ts
declare function pad(s: string, n: number, direction: "left" | "right"): string;
pad("hi", 10, "left");
Try

Cuando es necesario, el compilador amplía (widens) — convierte a un supertipo — el tipo unitario al tipo primitivo, como "foo" a string. Esto sucede cuando se usa mutabilidad, lo que puede obstaculizar algunos usos de variables mutables:

ts
let s = "right";
pad("hi", 10, s); // error: 'string' no es asignable a '"left" | "right"'
Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.2345Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.
Try

Así es como ocurre el error:

  • "right": "right"
  • s: string porque "right" se amplía a string en la asignación a una variable mutable.
  • string no es asignable a "left" | "right"

Puedes solucionar esto con una anotación de tipo para s, pero eso a su vez impide asignaciones a s de variables que no sean de tipo "left" | "right".

ts
let s: "left" | "right" = "right";
pad("hi", 10, s);
Try

Conceptos similares a Haskell

Tipado Contextual

TypeScript tiene algunos lugares obvios donde puede inferir tipos, como declaraciones de variables:

ts
let s = "¡Soy una cadena!";
Try

Pero también infiere tipos en algunos otros lugares que quizás no esperes si has trabajado con otros lenguajes de sintaxis C:

ts
declare function map<T, U>(f: (t: T) => U, ts: T[]): U[];
let sns = map((n) => n.toString(), [1, 2, 3]);
Try

Aquí, n: number en este ejemplo también, a pesar del hecho de que T y U no han sido inferidos antes de la llamada. De hecho, después de que [1,2,3] ha sido usado para inferir T=number, el tipo de retorno de n => n.toString() se usa para inferir U=string, haciendo que sns tenga el tipo string[].

Nota que la inferencia funcionará en cualquier orden, pero intellisense solo funcionará de izquierda a derecha, por lo que TypeScript prefiere declarar map con el array primero:

ts
declare function map<T, U>(ts: T[], f: (t: T) => U): U[];
Try

El tipado contextual también funciona recursivamente a través de literales objeto, y en tipos unitarios que de otro modo se inferirían como string o number. Y puede inferir tipos de retorno desde el contexto:

ts
declare function run<T>(thunk: (t: T) => void): T;
let i: { inference: string } = run((o) => {
o.inference = "INSERTAR ESTADO AQUÍ";
});
Try

El tipo de o se determina como { inference: string } porque

  1. Los inicializadores de declaración son tipados contextualmente por el tipo de la declaración: { inference: string }.
  2. El tipo de retorno de una llamada usa el tipo contextual para inferencias, por lo que el compilador infiere que T={ inference: string }.
  3. Las funciones flecha usan el tipo contextual para tipar sus parámetros, por lo que el compilador asigna o: { inference: string }.

Y lo hace mientras escribes, de modo que después de escribir o., obtienes autocompletado para la propiedad inference, junto con cualquier otra propiedad que tendrías en un programa real. En conjunto, esta característica puede hacer que la inferencia de TypeScript parezca un poco como un motor de inferencia de tipos unificador, pero no lo es.

Alias de Tipo

Los alias de tipo son meros alias, al igual que type en Haskell. El compilador intentará usar el nombre del alias dondequiera que se usó en el código fuente, but no siempre lo logra.

ts
type Size = [number, number];
let x: Size = [101.1, 999.9];
Try

El equivalente más cercano a newtype es una intersección etiquetada:

ts
type FString = string & { __compileTimeOnly: any };

Una FString es como una cadena normal, excepto que el compilador cree que tiene una propiedad llamada __compileTimeOnly que en realidad no existe. Esto significa que FString todavía se puede asignar a string, pero no al revés.

Uniones Discriminadas

El equivalente más cercano a data es una unión de tipos con propiedades discriminantes, normalmente llamadas uniones discriminadas en TypeScript:

ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };

A diferencia de Haskell, la etiqueta, o discriminante, es solo una propiedad en cada tipo objeto. Cada variante tiene una propiedad idéntica con un tipo unitario diferente. Esto sigue siendo un tipo unión normal; el | inicial es una parte opcional de la sintaxis del tipo unión. Puedes discriminar los miembros de la unión usando código JavaScript normal:

ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
 
function area(s: Shape) {
if (s.kind === "circle") {
return Math.PI * s.radius * s.radius;
} else if (s.kind === "square") {
return s.x * s.x;
} else {
return (s.x * s.y) / 2;
}
}
Try

Nota que el tipo de retorno de area se infiere como number porque TypeScript sabe que la función es total. Si alguna variante no está cubierta, el tipo de retorno de area será number | undefined en su lugar.

Además, a diferencia de Haskell, las propiedades comunes aparecen en cualquier unión, por lo que puedes discriminar útilmente múltiples miembros de la unión:

ts
function height(s: Shape) {
if (s.kind === "circle") {
return 2 * s.radius;
} else {
// s.kind: "square" | "triangle"
return s.x;
}
}
Try

Parámetros de Tipo

Como la mayoría de los lenguajes descendientes de C, TypeScript requiere la declaración de parámetros de tipo:

ts
function liftArray<T>(t: T): Array<T> {
return [t];
}

No hay requisito de mayúsculas/minúsculas, pero los parámetros de tipo son convencionalmente letras mayúsculas únicas. Los parámetros de tipo también pueden restringirse a un tipo, lo que se comporta un poco como las restricciones de clases de tipo (type class):

ts
function firstish<T extends { length: number }>(t1: T, t2: T): T {
return t1.length > t2.length ? t1 : t2;
}

TypeScript generalmente puede inferir argumentos de tipo de una llamada basándose en el tipo de los argumentos, por lo que los argumentos de tipo generalmente no son necesarios.

Debido a que TypeScript es estructural, no necesita parámetros de tipo tanto como los sistemas nominales. Específicamente, no son necesarios para hacer una función polimórfica. Los parámetros de tipo solo deben usarse para propagar información de tipo, como restringir parámetros para que sean del mismo tipo:

ts
// Mal: T no se usa para relacionar tipos de parámetros
function lengthBad<T extends ArrayLike<unknown>>(t: T): number {}
// Bueno: Sin parámetro de tipo innecesario
function lengthGood(t: ArrayLike<unknown>): number {}

En el primer length, T no es necesario; observa que solo se referencia una vez, por lo que no se usa para restringir el tipo del valor de retorno u otros parámetros.

Tipos de orden superior

TypeScript no tiene tipos de orden superior (higher kinded types), por lo que lo siguiente no es legal:

ts
// No es legal
function length<T extends ArrayLike<unknown>, U>(m: T<U>) {}

Programación sin puntos

La programación sin puntos (point-free) — uso intensivo de currificación y composición de funciones — es posible en JavaScript, pero puede ser verbosa. En TypeScript, la inferencia de tipos a menudo falla para programas sin puntos, por lo que terminarás especificando parámetros de tipo en lugar de parámetros de valor. El resultado es tan verboso que generalmente es mejor evitar la programación sin puntos.

Sistema de Módulos

La sintaxis moderna de módulos de JavaScript es un poco como la de Haskell, excepto que cualquier archivo con import o export es implícitamente un módulo:

ts
import { value, Type } from "npm-package";
import { other, Types } from "./local-package";
import * as prefix from "../lib/third-package";

También puedes importar módulos commonjs — módulos escritos usando el sistema de módulos de node.js:

ts
import f = require("single-function-package");

Puedes exportar con una lista de exportación:

ts
export { f };
function f() {
return g();
}
function g() {} // g no se exporta

O marcando cada exportación individualmente:

ts
export function f() { return g() }
function g() { } // g no se exporta

El último estilo es más común pero ambos están permitidos, incluso en el mismo archivo.

readonly y const

En JavaScript, la mutabilidad es el valor predeterminado, aunque permite declaraciones de variables con const para declarar que la referencia es inmutable. El referente sigue siendo mutable:

js
const a = [1, 2, 3];
a.push(102); // ):
a[0] = 101; // D:

TypeScript adicionalmente tiene un modificador readonly para propiedades.

ts
interface Rx {
readonly x: number;
}
let rx: Rx = { x: 1 };
rx.x = 12; // error

También incluye un tipo mapeado Readonly<T> que hace que todas las propiedades sean readonly:

ts
interface X {
x: number;
}
let rx: Readonly<X> = { x: 1 };
rx.x = 12; // error

Y tiene un tipo específico ReadonlyArray<T> que elimina métodos con efectos secundarios y previene la escritura en los índices del array, así como una sintaxis especial para este tipo:

ts
let a: ReadonlyArray<number> = [1, 2, 3];
let b: readonly number[] = [1, 2, 3];
a.push(102); // error
b[0] = 101; // error

También puedes usar una aserción const, que opera en arrays y literales objeto:

ts
let a = [1, 2, 3] as const;
a.push(102); // error
a[0] = 101; // error

Sin embargo, ninguna de estas opciones es la predeterminada, por lo que no se usan consistentemente en el código TypeScript.

Próximos Pasos

Este documento es una descripción general de alto nivel de la sintaxis y los tipos que usarías en el código diario. Desde aquí deberías:

The TypeScript docs are an open source project. Help us improve these pages by sending a Pull Request

Contributors to this page:
FKFabián Karaben  (6)

Last updated: 02 may 2025