Inicio / Artículos / Comportamientos de TypeScript que sorprenden a los desarrolladores experimentados, y por qué

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.

4295 palabras

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.
  • Preferir unknown en lugar de any y satisfies en lugar de as cuando su objetivo sea verificar, no sobrescribir.
  • Utilice uniones discriminadas y 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

  • Diez patrones de TypeScript que convierten errores en el tiempo de ejecución en errores de compilación — Aprenda diez técnicas de TypeScript, desde uniones discriminadas y satisfies hasta tipos marcados e infer, que permiten al compilador rechazar estados inválidos antes de que su código sea distribuido.
  • Lo que el compilador de TypeScript detecta y lo que JavaScript permite pasar desapercibido — Compare JavaScript y TypeScript lado a lado: inferencia de tipos, anotaciones, primitivos, any, uniones y funciones tipadas, además de lo que realmente marca y emite tsc.