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 los programas tradicionales orientados a objetos a la web. A medida que se desarrolló, el sistema de tipos de TypeScript evolucionó para modelar el código escrito por usuarios nativos de JavaScript. El sistema resultante es poderoso, interesante y complejo.
Esta introducción está diseñada para programadores que trabajan en Haskell o ML y desean aprender TypeScript. Describe en qué se diferencia el sistema de tipos de TypeScript 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 OO.
Requisitos previos
En esta introducción, asumo que sabes lo siguiente:
- Cómo programar en JavaScript, la parte buena.
- Escribir sintaxis 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 alcance léxico de llamada por valor con mucha mutabilidad y no mucho más. Esquema RRS ↗ es un buen ejemplo.
El lenguaje de programación C++ ↗ es un buen lugar para aprender sobre la sintaxis de tipos de estilo C. A diferencia de C++, TypeScript usa tipos postfix, como este: x: string
en lugar de string x
.
Conceptos que existen en Haskell
Tipos incorporados
JavaScript define 8 tipos integrados:
Tipo | Explicación |
---|---|
Number |
un punto flotante IEEE 754 de doble precisión. |
String |
una cadena UTF-16 inmutable. |
BigInt |
enteros en el formato de precisión arbitraria. |
Boolean |
true y false . |
Symbol |
un valor único que normalmente se utiliza como clave. |
Null |
equivalente al tipo de unidad. |
Undefined |
también equivalente al tipo de unidad. |
Object |
similar a records. |
Consulta la página de MDN para obtener más detalles ↗.
TypeScript tiene tipos primitivos correspondientes para los tipos integrados:
number
string
bigint
boolean
symbol
null
undefined
object
Otros tipos importantes de TypeScript
Tipo | Explicación |
---|---|
unknown |
el tipo superior. |
never |
el tipo inferior. |
object literal | por ejemplo, { property: Type } |
void |
para funciones sin valor de retorno documentado |
T[] |
arrays mutables, también escritos Array<T> |
[T, T] |
tuplas, que son de longitud fija pero mutables |
(t: T) => U |
funciones |
Notas:
- La sintaxis de las funciones incluye nombres de parámetros. ¡Es bastante difícil acostumbrarse a esto!
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;
- La sintaxis del tipo literal del objeto refleja fielmente la sintaxis del valor literal del objeto:
let o: { n: number; xs: object[] } = { n: 1, xs: [] };
[T, T]
es un subtipo deT[]
. Esto es diferente a Haskell, donde las tuplas no están relacionadas con listas.
Tipos boxed
JavaScript tiene equivalentes 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 boxed Number
. Los tipos boxed rara vez son necesarios, ya que sus métodos devuelven primitivos.
(1).toExponential();
// es equivalente a
Number.prototype.toExponential.call(1);
Ten en cuenta que llamar a un método en un literal numérico requiere que esté entre paréntesis para ayudar al analizador.
Tipado gradual
TypeScript usa el tipo any
siempre que no puede decir cuál debería ser el tipo de una expresión. En comparación con Dynamic
, llamar tipo any
es una exageración. Simplemente apaga el verificador de tipos dondequiera que aparezca. Por ejemplo, puedes insertar cualquier valor en array any[]
sin marcar el valor de ninguna manera:
// con "noImplicitAny": false en tsconfig.json, anys: any[]
const anys = [];
anys.push(1);
anys.push("oh no");
anys.push({ anything: "goes" });
Y puedes usar una expresión de tipo any
en cualquier lugar:
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 el tipo any
.
let sepsis = anys[0] + anys[1]; // esto puede significar cualquier cosa
Para obtener un error cuando TypeScript produce any
, usa "noImplicitAny": true
o "strict": true
en tsconfig.json
.
Tipificación 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 están tipificados estructuralmente. Su forma básica es bastante simple:
// @strict: false
let o = { x: "hi", extra: 1 }; // ok
let o2: { x: string } = o; // ok
Aquí, el objeto literal { x: "hi", extra: 1 }
tiene un tipo literal coincidente { x: string, extra: number }
. Ese tipo se puede asignar a { x: string }
ya que tiene todas las propiedades requeridas y esas propiedades tienen tipos asignables. La propiedad adicional no impide la asignación, simplemente la convierte en un subtipo de { x: string }
.
Los tipos con nombre simplemente le dan un nombre a un tipo; para fines 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
. (Sin embargo, los alias de tipo se comportan de manera diferente a las interfaces con respecto a las definiciones recursivas y los parámetros de tipo).
type One = { p: string };
interface Two {
p: string;
}
class Three {
p = "Hello";
}
let x: One = { p: "hi" };
let two: Two = x;
two = new Three();
Uniones
En TypeScript, los tipos de unión no están etiquetados. En otras palabras, no son uniones discriminadas como los de data
en Haskell. Sin embargo, a menudo es posible discriminar tipos en una unión mediante etiquetas integradas u otras propiedades.
function start(
arg: string | string[] | (() => string) | { s: string }
): string {
// this is super common in 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 {
// finally, just convert a string to another string
return s;
}
}
string
, Array
y Function
tienen predicados de tipo incorporados, dejando convenientemente el tipo de objeto para la rama else
. Sin embargo, es posible generar uniones que sean difíciles de diferenciar en tiempo de ejecución. Para código nuevo, es mejor compilar solo
uniones discriminadas.
Los siguientes tipos tienen predicados integrados:
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" |
Ten en cuenta que las funciones y los arreglos son objetos en tiempo de ejecución, pero tienen sus propios predicados.
Intersecciones
Además de uniones, TypeScript también tiene intersecciones:
type Combined = { a: number } & { b: string };
type Conflicting = { a: number } & { a: string };
Combined
tiene dos propiedades, a
y b
, como si hubieran sido escritas como un tipo literal de objeto. La intersección y la unión son recursivas en caso de conflictos, por lo que Conflicting.a: number & string
.
Tipos de unidades
Los tipos de unidades 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 integradas, es común utilizar en su lugar un conjunto de cadenas conocidas. Las uniones de tipos literales de cadena permiten a TypeScript escribir este patrón:
declare function pad(s: string, n: number, direction: "left" | "right"): string;
pad("hi", 10, "left");
Cuando es necesario, el compilador amplia - convierte a un supertipo - el tipo de unidad al tipo primitivo, como "foo"
a string
. Esto sucede cuando se utiliza la mutabilidad, lo que puede dificultar algunos usos de variables mutables:
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"'.
Así es como ocurre el error:
"right": "right"
s: string
porque"right"
se amplía astring
al asignarse a una variable mutable.string
no se puede asignar a"left" | "right"
Puedes solucionar esto con una anotación de tipo para s
, pero eso a su vez evita asignaciones a s
de variables que no son de tipo "left" | "right"
.
let s: "left" | "right" = "right";
pad("hi", 10, s);
Conceptos similares a Haskell
Tipado contextual
TypeScript tiene algunos lugares obvios donde puede inferir tipos, como declaraciones de variables:
let s = "¡Soy un string!";
Pero también infiere tipos en algunos otros lugares que quizás no esperes si has trabajado con otros lenguajes de sintaxis C:
declare function map<T, U>(f: (t: T) => U, ts: T[]): U[];
let sns = map((n) => n.toString(), [1, 2, 3]);
Aquí, también en este ejemplo es n: number
, a pesar de que T
y U
no se han inferido antes de la llamada. De hecho, después de que se haya usado [1,2,3]
para inferir T=number
, el tipo de retorno de n => n.toString()
se usa para inferir U=string
, causando que sns
sea del tipo string[]
.
Ten en cuenta 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:
declare function map<T, U>(ts: T[], f: (t: T) => U): U[];
El tipado contextual también funciona de forma recursiva a través de objetos literales y en tipos de unidades que de otro modo se inferirían como string
o number
. Y puede inferir tipos de retorno a partir del contexto:
declare function run<T>(thunk: (t: T) => void): T;
let i: { inference: string } = run((o) => {
o.inference = "INSERT STATE HERE";
});
Se determina que el tipo de o
es { inference: string }
porque
- Los inicializadores de declaración están tipados contextualmente según el tipo de declaración:
{ inference: string }
. - El tipo de retorno de una llamada utiliza el tipo contextual para las inferencias, por lo que el compilador infiere que
T={ inference: string }
. - Las funciones de flecha usan el tipo contextual para asignar tipos a sus parámetros, por lo que el compilador proporciona
o: {inference: string}
.
Y lo hace mientras escribes, de modo que después de escribir o.
, obtienes terminaciones 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 se parezca un poco a un motor de inferencia de tipos unificador, pero no lo es.
Alias de tipos
Los alias de tipos son meros alias, como type
en Haskell. El compilador intentará utilizar el nombre de alias siempre que se haya utilizado en el código fuente, pero no siempre lo consigue.
type Size = [number, number];
let x: Size = [101.1, 999.9];
El equivalente más cercano a newtype
es una intersección etiquetada:
type FString = string & { __compileTimeOnly: any };
Un 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” aún 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:
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 de objeto. Cada variante tiene una propiedad idéntica con un tipo de unidad diferente. Este sigue siendo un tipo de unión normal; el |
inicial es una parte opcional de la sintaxis del tipo de unión. Puedes discriminar a los miembros de la unión usando código JavaScript normal:
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;
}
}
Ten en cuenta que se infiere que el tipo de retorno de area
es 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 de manera útil a varios miembros de la unión:
function height(s: Shape) {
if (s.kind === "circle") {
return 2 * s.radius;
} else {
// s.kind: "square" | "triangle"
return s.x;
}
}
Parámetros de tipo
Como la mayoría de los lenguajes descendientes de C, TypeScript requiere una declaración de parámetros de tipo:
function liftArray<T>(t: T): Array<T> {
return [t];
}
No hay ningún requisito de mayúsculas y minúsculas, pero los parámetros de tipo son convencionalmente letras mayúsculas individuales. Los parámetros de tipo también se pueden restringir a un tipo, que se comporta un poco como restricciones de clase de tipo:
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 a partir de una llamada basada en el tipo de 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 que una función sea polimórfica. Los parámetros de tipo solo deben usarse para propagar información de tipo, como restringir los parámetros para que sean del mismo tipo:
function length<T extends ArrayLike<unknown>>(t: T): number {}
function length(t: ArrayLike<unknown>): number {}
En el primer length
, T
no es necesaria; observa que solo se hace referencia a ella una vez, por lo que no se usa para restringir el tipo de valor de retorno u otros parámetros.
Tipos de tipo superior
TypeScript no tiene tipos de tipo superior, por lo que lo siguiente no es legal:
function length<T extends ArrayLike<unknown>, U>(m: T<U>) {}
Programación sin puntos
La programación sin puntos (uso intensivo de currying y composición de funciones) es posible en JavaScript, pero puede ser verboso. En TypeScript, la inferencia de tipos a menudo falla en 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 normalmente es mejor evitar la programación sin puntos.
Sistema de módulos
La sintaxis del módulo moderno de JavaScript es un poco como la de Haskell, excepto que cualquier archivo con import
o export
es implícitamente un módulo:
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 utilizando el sistema de módulos de node.js:
import f = require("single-function-package");
Puedes exportar con una lista de exportación:
export { f };
function f() {
return g();
}
function g() {} // g is not exported
O marcando cada exportación individualmente:
export function f() { return g() }
function g() { }
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 la opción predeterminada, aunque permite declaraciones de variables con const
para declarar que la referencia es inmutable. El referente sigue siendo mutable:
const a = [1, 2, 3];
a.push(102); // ):
a[0] = 101; // D:
TypeScript además tiene un modificador de readonly
para las propiedades.
interface Rx {
readonly x: number;
}
let rx: Rx = { x: 1 };
rx.x = 12; // error
También viene con un tipo mapeado Readonly<T>
que hace que todas las propiedades sean readonly
:
interface X {
x: number;
}
let rx: Readonly<X> = { x: 1 };
rx.x = 12; // error
Y tiene un tipo ReadonlyArray<T>
específico que elimina los métodos que afectan lateralmente y evita la escritura en índices del array, así como una sintaxis especial para este tipo:
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 constante, que opera en arrays y objetos literales:
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 de manera consistente 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 cotidiano. Desde aquí deberías:
- Lee el Manual completo de principio a fin
- Explora los ejemplos de Playground ↗