Comportamientos de TypeScript que sorprenden a los desarrolladores experimentados, y por qué
Tipado estructural, verificaciones excesivas de propiedades, uso de as const, satisfies, tipos condicionales y mapeados, además de los principios de diseño que los convierten en código más seguro.
La mayoría de los desarrolladores comienzan tratando a TypeScript como JavaScript con anotaciones, y luego se encuentran con comportamientos que no encajan en esa imagen: un objeto con campos adicionales es aceptado en un lugar y rechazado en otro, una aserción de tipo no “convierte” nada, y readonly sigue permitiendo que un valor anidado cambie. Debajo de las anotaciones básicas existe un lenguaje a nivel de tipo con sus propias reglas para la compatibilidad, la inferencia y el cálculo. Esta guía explica esas sorpresas una por una, desde la eliminación en tiempo de ejecución y el tipado estructural hasta infer, los tipos de literales de plantilla y satisfies, y luego las convierte en principios de diseño prácticos que puede aplicar a un código real.
Los tipos dejan de existir cuando se ejecuta el código
Aquí hay una interfaz y un objeto anotado con ella:
interface User {
id: number;
name: string;
}
const user: User = {
id: 1,
name: "Lakhveer"
};
Es tentador pensar que el programa en ejecución sabe que user es un User. Pero no lo sabe. La compilación elimina la interfaz, y lo que queda es esencialmente esto:
const user = {
id: 1,
name: "Lakhveer"
};
No existe ningún valor de User en tiempo de ejecución. TypeScript realiza primero un análisis estático y luego genera JavaScript; solo ese JavaScript llega al motor:
TypeScript
↓
Type Checking
↓
JavaScript Generation
↓
Browser / Node.js
Los tipos informan al compilador; no son objetos en tiempo de ejecución. La consecuencia práctica es que TypeScript nunca valida los datos que provienen del exterior de su programa. La anotación a continuación solo afirma lo que devuelve el servidor; nada lo verifica:
const response: User = await fetch("/api/user")
.then(res => res.json());
Para datos externos se necesita validación en tiempo de ejecución. Una biblioteca de esquemas como Zod describe la estructura una vez y la verifica cuando llegan los datos:
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const user = UserSchema.parse(data);
Una forma útil de recordar esta división: el compilador protege tu código, mientras que la validación en tiempo de ejecución protege tu aplicación. La guía del blog sobre compartir un mismo esquema Zod entre una interfaz frontend de React y un backend de Node muestra cómo aplicar esto en ambos extremos.
any, unknown y la carga de la prueba
any desactiva las verificaciones
Con any, cada una de estas operaciones sin sentido se compila sin problemas:
let value: any = "hello";
value.foo.bar.baz();
value();
value.notARealProperty;
any le indica efectivamente al compilador que confíe en ti y deje de verificar. Por eso un parámetro tipado de esta manera descarta silenciosamente la mayor parte de las ventajas de TypeScript:
function processUser(user: any) {
console.log(user.name);
}
unknown exige pruebas
unknown también acepta cualquier valor:
let value: unknown = "hello";
Pero usarlo directamente falla:
value.foo;
Primero hay que restringirlo, por ejemplo a una cadena:
if (typeof value === "string") {
console.log(value.toUpperCase());
}
o a un número:
if (typeof value === "number") {
console.log(value.toFixed(2));
}
La diferencia en la actitud es fácil de resumir:
any
↓
"Trust me"
unknown
↓
"Prove it first"
Así que cuando realmente no se conoce el tipo de un valor, recurra a
unknown
en lugar de
any
y deje que el compilador le obligue a demostrar qué tiene antes de usarlo.
La compatibilidad se refiere a la estructura, no a los nombres
Los desarrolladores provenientes de Java, C# o C++ a menudo se sorprenden de que esta asignación esté permitida:
interface User {
name: string;
}
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
TypeScript utiliza tipado estructural: la compatibilidad depende de las propiedades que tiene un valor, no de cómo fue declarado. User solo necesita esto:
name: string
y employee lo tiene, además de más. El razonamiento que aplica el compilador es el siguiente:
User requires:
name: string
employee has:
name: string
salary: number
Therefore:
employee satisfies User
o, como flujo de decisiones:
Required properties
↓
Does object contain them?
↓
Yes
↓
Compatible
Las verificaciones excesivas de propiedades se aplican a los literales nuevos
Ahora viene lo interesante: escribir un campo adicional directamente en un literal de objeto es rechazado:
interface User {
name: string;
}
const user: User = {
name: "Lakhveer",
salary: 100000
};
con un error como este:
Object literal may only specify known properties
Pero asignar los mismos datos a través de una variable intermedia sí es aceptado:
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
La razón es que TypeScript realiza verificaciones excesivas de propiedades en los literales de objeto nuevos, aquellos escritos directamente en el momento de la asignación, como medida de protección contra errores tipográficos. Esa verificación es distinta a la compatibilidad estructural. Por lo tanto, “TypeScript rechaza propiedades adicionales” solo es cierto para los literales; una vez que un objeto ha pasado por una variable, los campos adicionales están permitidos.
Tipos literales y uniones derivadas
Valores exactos en lugar de tipos amplios
Una variable puede restringirse a valores específicos:
let direction: "left" | "right";
direction = "left";
Por lo tanto, esta asignación falla:
direction = "up";
La declaración no indica
direction: string
Indica
direction must be EXACTLY:
"left"
OR
"right"
Que la precisión mejora considerablemente las APIs. Una función de despliegue solo puede aceptar entornos conocidos:
type Environment =
| "development"
| "staging"
| "production";
function deploy(env: Environment) {
// ...
}
Y un error de escritura se convierte en un error de compilación en lugar de un despliegue fallido:
deploy("testing");
as const cambia lo que se infiere
Un literal de array ordinario de cadenas:
const colors = ["red", "blue", "green"];
se infiere como
string[]
Añadiendo as const:
const colors = ["red", "blue", "green"] as const;
se obtiene en su lugar una tupla de solo lectura de tipos literales:
readonly ["red", "blue", "green"]
De esa tupla se puede derivar una unión indexando con number:
type Color = typeof colors[number];
lo cual produce:
type Color = "red" | "blue" | "green";
Esto elimina una duplicación común. Sin ello, se debe mantener tanto una unión como un array que deben sincronizarse manualmente:
type Color = "red" | "blue" | "green";
const colors: Color[] = [
"red",
"blue",
"green"
];
Con esto, el array se convierte en la única fuente de verdad y el tipo se determina automáticamente:
const colors = [
"red",
"blue",
"green"
] as const;
type Color = typeof colors[number];
El principio se generaliza: cuando el sistema de tipos puede derivar información, no la escribas dos veces.
Palabras clave que funcionan a nivel de tipo
typeof tiene dos funciones
En JavaScript,
typeof value
es un operador en tiempo de ejecución. Por ejemplo,
typeof "hello";
da como resultado
"string"
En una posición de tipo, TypeScript reutiliza la palabra clave para capturar el tipo estático de una variable:
const user = {
id: 1,
name: "Lakhveer"
};
type User = typeof user;
Aquí
User
se convierte en
{
id: number;
name: string;
}
Misma palabra clave, dos contextos:
Runtime:
typeof value
Type system:
typeof variable
keyof convierte las claves en una unión
Dada una interfaz,
interface User {
id: number;
name: string;
email: string;
}
Este tipo
type UserKeys = keyof User;
es
"id" | "name" | "email"
Combinado con genéricos, esto permite escribir un accesor de propiedad que solo acepta claves reales:
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
Llamarlo con una clave existente funciona:
const user = {
id: 1,
name: "Lakhveer"
};
getValue(user, "name");
mientras que una clave faltante es rechazada en tiempo de compilación:
getValue(user, "salary");
El tipo de retorno también es preciso: T[K] corresponde al tipo de esa propiedad en particular.
Los genéricos conectan valores entre sí
El genérico básico simplemente devuelve lo que recibe:
function identity<T>(value: T): T {
return value;
}
Los genéricos se vuelven más interesantes cuando vinculan varios valores. Aquí ambos argumentos deben compartir un mismo tipo:
function pair<T>(first: T, second: T): [T, T] {
return [first, second];
}
por lo que esta llamada es aceptada:
pair(10, 20);
Pero este falla, porque T se infiere a partir del primer argumento como number y no se puede asignarle una cadena:
pair(10, "hello");
Los genericos también pueden conectar la entrada con la salida de manera directa, incluyendo el caso vacío:
function first<T>(items: T[]): T | undefined {
return items[0];
}
Para una llamada como
const numbers = first([1, 2, 3]);
el compilador muestra el resultado como
number | undefined
Cálculo de tipos
Los tipos condicionales son un if a nivel de tipo
Un tipo condicional elige entre dos resultados según una verificación:
type IsString<T> =
T extends string
? true
: false;
Por lo tanto
type A = IsString<string>;
se resuelve en
true
y
type B = IsString<number>;
se resuelve en
false
Conceptualmente estás escribiendo esto, solo que se ejecuta en el compilador en lugar de en tu programa:
if T is string
return true
else
return false
infer extrae partes de un tipo
Dentro de un tipo condicional, infer introduce una variable de tipo que TypeScript completa mediante coincidencias. Esto reimplementa el ReturnType incorporado:
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R
? R
: never;
Al aplicarlo a una función real, extrae el tipo del objeto devuelto:
function getUser() {
return {
id: 1,
name: "Lakhveer"
};
}
type User = ReturnTypeOf<typeof getUser>;
El modelo mental es un patrón de coincidencia:
Function
↓
infer R
↓
Extract return type
Muchos de los tipos de utilidad estándar se construyen exactamente de esta manera.
Los tipos mapeados transforman cada propiedad
Partiendo de una interfaz,
interface User {
id: number;
name: string;
email: string;
}
un tipo mapeado itera sobre sus claves para generar una versión de solo lectura:
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
o una opcional:
type OptionalUser = {
[K in keyof User]?: User[K];
};
en lugar de reescribir cada campo a mano:
id?: number;
name?: string;
email?: string;
Los tipos de utilidad se construyen a partir de estas piezas
TypeScript incluye una biblioteca con tales ayudantes:
Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>
Dado un modelo como este,
interface User {
id: number;
name: string;
email: string;
}
Pick mantiene las claves seleccionadas:
type UserPreview = Pick<User, "id" | "name">;
generando
{
id: number;
name: string;
}
y Omit las elimina:
type UserWithoutEmail = Omit<User, "email">;
Para conocer el conjunto completo, consulte la guía del blog sobre los tipos de utilidad integrados de TypeScript.
never, narrowing y guards
never demuestra que se ha manejado todo
never es el tipo de un valor que no puede existir, como el resultado de una función que siempre lanza un error:
function fail(message: string): never {
throw new Error(message);
}
Su verdadero poder se muestra en las comprobaciones de exhaustividad. Tome una unión de estados:
type Status =
| "loading"
| "success"
| "error";
y un switch cuya rama default pasa el valor a una función que solo acepta never:
function handleStatus(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
function assertNever(value: never): never {
throw new Error("Unexpected value: " + value);
}
Cuando se ha manejado cada caso, el valor de status ya se ha reducido a never para cuando llega al bloque default, por lo que el compilador realiza las comprobaciones de tipo. Ahora supongamos que alguien extiende esa unión:
type Status =
| "loading"
| "success"
| "error"
| "cancelled";
El caso no manejado "cancelled" llega a assertNever; no se puede asignar a never, y el compilador señala cada estructura switch que necesita actualización.
La reducción sigue el flujo de control
El compilador registra cómo las comprobaciones modifican los valores posibles de una variable:
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
En la primera rama,
value
se sabe que es
string
y en la segunda es
number
Guardianes de tipo personalizados
Puedes enseñar al compilador a reconocer tus propios tipos mediante una función cuyo tipo de retorno sea un predicado de tipo, value is User:
interface User {
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value
);
}
Después de que la comprobación tenga éxito,
const data: unknown = getData();
if (isUser(data)) {
console.log(data.name);
}
el compilador trata el valor como
data: User
dentro del bloque. Tenga en cuenta que el compilador confía plenamente en el predicado. Esta comprobación solo verifica que name exista, no que sea una cadena de texto; por lo tanto, una comprobación descuidada equivale efectivamente a una afirmación sin verificar.
Modelado del estado con uniones discriminadas
Un diseño común pero deficiente consiste en incluir todas las posibilidades en un único objeto con campos opcionales:
interface State {
status: string;
data?: User;
error?: string;
}
Una unión discriminada modela cada estado por separado, etiquetado mediante status:
type State =
| {
status: "loading";
}
| {
status: "success";
data: User;
}
| {
status: "error";
error: string;
};
Al seleccionar la etiqueta, cada rama se reduce a los campos que existen allí:
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error;
}
}
Esto excluye contradicciones como
status = success
error = "Something went wrong"
lo cual la versión sin restricciones permite felizmente. El principio rector es modelar estados válidos en lugar de permitir aquellos inválidos y verificarlos en todas partes.
satisface las verificaciones sin sobrescribir
satisfies verifica que una expresión se ajuste a un tipo manteniendo al mismo tiempo el tipo inferido por la propia expresión:
const config = {
port: 3000,
host: "localhost"
} satisfies {
port: number;
host: string;
};
Eso es ideal para objetos de configuración, donde se desea realizar la verificación pero también preservar valores literales y claves exactas. Compare con una aserción:
const config = {...} as Config;
as le indica al compilador que trate el valor como ese tipo, y aceptará mucho basándose en su palabra. satisfies, por el contrario, pide al compilador que confirme la conformidad. Cuando su intención es la validación y no sobrescribir el verificador, prefiera
satisfies
sobre
as
Límites que sorprenden a las personas
readonly es superficial
Considere un tipo con propiedades de solo lectura, una de las cuales es un objeto:
type User = {
readonly name: string;
readonly address: {
city: string;
};
};
Se bloquea la reasignación de la propiedad de nivel superior:
user.name = "New Name";
pero aún se permite cambiar un campo dentro del objeto anidado:
user.address.city = "Indore";
readonly solo se aplica a la propiedad que marca, no de forma recursiva. La inmutabilidad profunda requiere un tipo mapeado recursivo o un mecanismo en tiempo de ejecución; además, tenga en cuenta que Object.freeze también es superficial.
Las afirmaciones no convierten valores
Esta doble afirmación se compila:
const value = "123" as unknown as number;
pero no se convierte nada. En tiempo de ejecución,
typeof value
se sigue informando
string
Si necesita un número, conviértalo explícitamente:
const value = Number("123");
Las afirmaciones cambian lo que cree el compilador, nunca el valor en sí.
Opcional no siempre es lo mismo que indefinido
Una propiedad opcional:
interface User {
name?: string;
}
suele significar que la clave puede estar ausente, por lo que se trata de un objeto vacío
{}
es válido, al igual que
{
name: "Lakhveer"
}
Pero si un valor explícito
{
name: undefined
}
está permitido depende de la configuración. Al activarla
{
"exactOptionalPropertyTypes": true
}
el compilador puede distinguir entre los dos. Esto es importante en APIs donde
property missing
y
property explicitly undefined
significan cosas diferentes; por ejemplo, en una solicitud PATCH donde un campo faltante significa “dejar sin cambios” y un valor explícito significa “borrarlo”.
El indexado puede ocultar valores indefinidos
TypeScript deliberadamente no intenta prevenir todos los errores en tiempo de ejecución. Leer más allá del final de un array:
const numbers = [1, 2, 3];
const value = numbers[100];
bajo la configuración por defecto, se trata como si siempre hubiera un número. Al activar
{
"noUncheckedIndexedAccess": true
}
permite el acceso
numbers[100]
informar como
number | undefined
lo que te obliga a manejar el caso faltante.
Tipos de cadena y relacionales
Tipos de literales de plantilla
TypeScript puede crear tipos de cadena a partir de otros tipos de cadena:
type EventName =
`user:${"created" | "updated" | "deleted"}`;
lo cual se expande a
"user:created"
"user:updated"
"user:deleted"
La misma técnica puede describir rutas:
type HttpMethod = "GET" | "POST";
type Endpoint =
`${HttpMethod} /users`;
así que los valores válidos son
"GET /users"
"POST /users"
Combinar las partes en APIs calculadas
Estas características se componen:
keyof
typeof
conditional types
mapped types
template literals
infer
generics
Comienza con un mapa de nombres de eventos a tipos de carga:
type EventMap = {
userCreated: {
id: number;
};
userDeleted: {
id: number;
};
};
Un emisor genérico puede luego vincular cada nombre de evento a su carga utilizando keyof y acceso por índice:
class EventEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(
event: K,
callback: (payload: Events[K]) => void
) {
// ...
}
emit<K extends keyof Events>(
event: K,
payload: Events[K]
) {
// ...
}
}
Emitar un evento conocido con la carga correcta se compila:
const emitter =
new EventEmitter<EventMap>();
emitter.emit("userCreated", {
id: 1
});
mientras que la carga incorrecta es rechazada:
emitter.emit("userCreated", {
name: "Lakhveer"
});
El compilador ahora comprende la relación entre el nombre de un evento y los datos que deben acompañarlo.
Modo estricto
Un proyecto profesional debería comenzar generalmente con:
{
"compilerOptions": {
"strict": true
}
}
Esa única bandera habilita una serie de verificaciones, entre las que se incluyen:
strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables
También activa opciones como strictBindCallApply y noImplicitThis. Añadir medidas de seguridad solo después de que aparecen errores es mucho más costoso que permitir que el compilador actúe como primera línea de defensa.
Hacer que los estados inválidos no sean representables
Considere un ciclo de vida de una solicitud modelado como una unión:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Compárelo con un diseño basado en banderas y campos opcionales:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
El segundo permite situaciones sin sentido como
{
loading: true,
data: user,
error: "Something failed"
}
Mientras que el primero hace que esas combinaciones sean imposibles de construir. Si hay un principio de diseño que se puede extraer de TypeScript, es este. Para un análisis más profundo, consulte modelado de dominios en TypeScript más allá de las anotaciones básicas.
Principios de diseño para TypeScript en entornos de producción
Saber qué características existen no es lo mismo que diseñar bien con ellas. Los siguientes principios se refieren a cómo dar forma a los sistemas.
Tratar cualquier cosa como último recurso
En lugar de
function process(data: any) {
// ...
}
preferir
function process(data: unknown) {
// validate/narrow first
}
y, cuando conozcas su estructura, aún mejor
function process(data: User) {
// ...
}
Usa any solo cuando entiendas exactamente qué estás renunciando.
Deja que la inferencia se encargue de lo obvio
Anotaciones como estas añaden ruido:
const name: string = "Lakhveer";
const age: number = 28;
El compilador ya lo sabe:
const name = "Lakhveer";
const age = 28;
Guarde los tipos explícitos en aquellos lugares donde documenten un contrato.
Deje que los tipos transmitan la intención
Una cadena simple dice muy poco:
function process(value: string) {}
Un tipo con nombre indica qué significa el valor:
type UserId = string;
function processUser(userId: UserId) {}
Una advertencia: un alias como UserId = string documenta la intención pero no impide pasar un ProductId donde se espera un UserId, ya que ambos son simplemente cadenas. Si mezclarlos representa un riesgo real, un tipo personalizado añade esa restricción.
Mantenga los tipos cerca del dominio
Una firma construida con cadenas simples:
function createOrder(
userId: string,
productId: string,
status: string
) {}
se vuelve mucho más clara con tipos de dominio:
type OrderStatus =
| "pending"
| "paid"
| "cancelled";
function createOrder(
userId: UserId,
productId: ProductId,
status: OrderStatus
) {}
Ahora el compilador entiende su vocabulario empresarial, no solo formas primitivas.
Prefiera uniones sobre flags booleanos
Los booleanos independientes permiten combinaciones imposibles:
interface State {
loading: boolean;
success: boolean;
error: boolean;
}
Una unión permite exactamente un estado a la vez:
type State =
| "loading"
| "success"
| "error";
Pasen a una unión discriminada cuando un estado necesite sus propios datos.
Validar en los límites del sistema
El compilador no puede garantizar nada que ingrese desde el exterior:
API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage
Traten todo esto como no fiable y enrútenlo a través de un único pipeline:
External data
↓
Runtime validation
↓
Trusted typed data
↓
Application logic
Los datos validados se convierten en datos tipificados y confiables, y solo esos llegan a la lógica de la aplicación.
Evitar el sobrediseño
Pueden escribir tipos extremadamente complejos, pero una firma como
type Something<T, U, V, X extends ...> = ...
que nadie en el equipo pueda explicar seis meses después es deuda técnica. Los tipos deben hacer que la base de código sea más clara, no demostrar ingenio.
Diseñar APIs para un uso correcto
Es fácil cometer errores al usar varias banderas de posición:
createUser(
"Lakhveer",
"admin",
true,
false,
undefined
);
Un objeto de opciones tipado es autoexplicativo y ofrece un autocompletado mucho mejor:
createUser({
name: "Lakhveer",
role: "admin",
active: true
});
Componer en lugar de crear interfaces enormes
Una única interfaz con docenas de campos
interface User {
// 50 properties
}
es más difícil de comprender que conceptos más pequeños combinados con intersecciones:
type Identifiable = {
id: string;
};
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User =
Identifiable &
Timestamped & {
name: string;
};
Haga que el compilador forme parte de su estrategia de pruebas
Los tipos no reemplazan las pruebas, pero eliminan categorías completas de errores antes de que se ejecuten las pruebas. Dado
type PaymentStatus =
| "pending"
| "paid"
| "failed";
añadir un nuevo miembro como
"refunded"
revelará, junto con las comprobaciones de exhaustividad, todos los lugares donde se olvidó manejarlo.
Mantenga separados en su mente el tiempo de compilación y el tiempo de ejecución
Pregúntese en qué capa está trabajando. Esto corresponde únicamente al tiempo de compilación:
interface User {
id: number;
}
Este es un control en tiempo de ejecución:
if (typeof value === "object") {
}
Y esta es la validación en tiempo de ejecución de datos externos:
UserSchema.parse(data);
Lea el JavaScript generado
Cuando el comportamiento sea confuso, pregúntese qué JavaScript se convierte en el código. Conocer ambas capas explica la mayoría de las sorpresas.
Conozca JavaScript a fondo
TypeScript se basa en JavaScript, por lo que los fundamentos siguen siendo importantes:
Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes
Mantenga tsconfig.json de forma intencionada
No copie una configuración a ciegas. Entienda qué aporta y qué implica cada opción:
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
Cada flag altera el equilibrio entre seguridad y conveniencia; por ejemplo, noImplicitOverride exige que se use override en cualquier método que reemplace uno de una clase base.
Un modelo mental en capas
Es útil imaginar a TypeScript como dos capas paralelas: JavaScript en tiempo de ejecución y un sistema de tipos en tiempo de compilación, donde las características a nivel de tipo se complementan entre sí:
TypeScript
│
┌────────────┴────────────┐
│ │
JavaScript Type System
│ │
Runtime Behavior Compile-Time Safety
│ │
Browser / Node Type Relationships
│
┌──────────┼──────────┐
│ │ │
Generics Unions Inference
│ │ │
keyof never conditional
│ │ │
mapped guards infer
│ │ │
└──────────┴──────────┘
Visto de esta manera, TypeScript deja de parecer un montón de reglas sintácticas y se convierte en un lenguaje para describir las relaciones entre valores: qué valores están permitidos, cómo se relacionan los objetos, qué estados pueden ocurrir, qué funciones aceptan y devuelven, y qué casos aún no se han manejado.
Puntos clave
Las características que más vale dominar no son las más llamativas:
Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
- Los tipos se eliminan en tiempo de ejecución, por lo que los datos externos siempre necesitan validación.
- La compatibilidad es estructural, con una verificación adicional solo en literales de objeto nuevos.
as const, typeof, keyof, los tipos condicionales y mapeados le permiten derivar tipos en lugar de duplicarlos.unknown en lugar de any y satisfies en lugar de as cuando su objetivo sea verificar, no sobrescribir.never para que el compilador pueda indicarle si un estado realmente puede ocurrir.El objetivo no son los tipos más sofisticados, sino un código en el que el compilador responda “¿puede ocurrir este estado?” antes incluso de que se ejecute el programa. Utilice TypeScript para diseñar código más seguro, no solo para describir el código que ya escribió.
Lecturas relacionadas
- Seis técnicas de TypeScript que convierten los tipos en una verdadera prevención de errores — Aprenda cómo satisfies, las uniones etiquetadas, never checks, unknown, los tipos derivados y los IDs con marca hacen que TypeScript detecte errores reales en tiempo de compilación en lugar de en producción.
- Modelado de dominios en TypeScript: más allá de las anotaciones de tipos básicas — Aprenda hábitos prácticos en TypeScript, desde unknown vs any hasta uniones discriminadas y satisfies, que le ayudan a modelar estados válidos en lugar de simplemente etiquetar datos.