Inicio / Artículos / Fundamentos de TypeScript: Desde la primera anotación hasta los genericos y el modo estricto.

Fundamentos de TypeScript: Desde la primera anotación hasta los genericos y el modo estricto.

Un recorrido estructurado por el sistema de tipos de TypeScript, desde los tipos primitivos e inferencia hasta las uniones discriminadas, los genericos, los tipos de utilidad y una configuración tsconfig estricta.

3750 palabras

Cualquier desarrollador de JavaScript conoce este patrón: el código funciona localmente, se envía y, dos días después, llega un informe de error porque una función recibió un objeto en lugar de una cadena, o porque undefined se introdujo en un cálculo. JavaScript no impide que escribas ese código; simplemente falla más tarde, en tiempo de ejecución, generalmente en producción. TypeScript traslada ese fallo al momento en que escribes la línea de código. Esta guía te llevará desde tu primer let x: number hasta los conceptos de unión discriminada, genéricos y tipos útiles, pasando por las configuraciones del compilador que determinan el nivel de protección que realmente obtienes, para que puedas leer y escribir código en TypeScript para producción con confianza.

Qué es TypeScript y qué no es

TypeScript es un lenguaje de código abierto desarrollado por Microsoft que añade un sistema de tipos estáticos a JavaScript. Aquí “estático” significa que el compilador verifica los tipos antes de la ejecución, en su editor o en tiempo de compilación, en lugar de descubrirlos durante la ejecución. Tres factores determinan todo lo demás:

  • Es un superconjunto de JavaScript. Cualquier código JavaScript sintácticamente válido también es válido desde el punto de vista de la sintaxis de TypeScript, por lo que está ampliando lo que ya conoce en lugar de empezar desde cero. El comprobador aún puede reportar errores en dicho código, y ese es precisamente el objetivo.
  • Se compila a JavaScript puro. Los navegadores y Node.js ejecutan JavaScript, por lo que el compilador tsc elimina las anotaciones y genera archivos ordinarios .js.
  • Los tipos desaparecen en tiempo de ejecución. Existen para protegerte durante el desarrollo. A diferencia de Java, donde la información del tipo sobrevive en el bytecode compilado, una vez que el programa está en ejecución ya no queda rastro alguno de los tipos de TypeScript.
  • La diferencia en una función

    Aquí hay una función ordinaria de JavaScript que suma dos valores:

    function add(a, b) {
      return a + b;
    }
    

    Al ser llamada con una cadena y un número, no lanza error alguno. Simplemente los concatena, y solo te das cuenta cuando el total de una factura parece extraño:

    add("10", 20); // "1020" — silently wrong
    

    Indicar los tipos de parámetros y devolución establece explícitamente el contrato:

    function add(a: number, b: number): number {
      return a + b;
    }
    

    Ahora, la misma llamada es rechazada mientras aún estás editando, con el error mostrado directamente debajo del argumento problemático:

    add("10", 20);
    // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
    

    Configuración de un proyecto

    Con Node.js instalado, puedes instalar el compilador de forma global y verificar que funciona:

    npm install -g typescript
    tsc --version
    

    En proyectos reales, instale TypeScript como dependencia de desarrollo local para que todos los colaboradores y el servidor CI utilicen la misma versión, y luego genere un archivo de configuración:

    npm install typescript --save-dev
    npx tsc --init
    

    El archivo tsconfig.json generado sirve como panel de control para determinar cuán estricto y moderno debe ser el compilador. Las versiones recientes de tsc --init ya activan el modo strict, y TypeScript 6.0 convirtió este modo en el predeterminado; sin embargo, los proyectos antiguos suelen ejecutarse con configuraciones más permisivas que los equipos van ajustando posteriormente. La sección de configuración a continuación aborda este tema.

    Un primer archivo mínimo declara una variable tipada y la registra:

    // app.ts
    let message: string = "Hello, TypeScript";
    console.log(message);
    

    Compílelo y luego ejecute el JavaScript generado:

    tsc app.ts     # produces app.js
    node app.js    # Hello, TypeScript
    

    Día a día, la mayoría de los proyectos evitan este flujo en dos pasos y ejecutan los archivos mediante ts-node o tsx, o dependen de un bundler como Vite, esbuild o webpack que compila en tiempo real. Aún así, vale la pena hacerlo a mano una vez, ya que establece el modelo mental correcto: se ingresa TypeScript y se obtiene JavaScript.

    Tipos básicos e inferencia

    Primitivos y arrays

    Las anotaciones para primitivos son string, number y boolean:

    let username: string = "sanajit";
    let age: number = 26;
    let isActive: boolean = true;
    

    Los arrays se escriben con el tipo del elemento seguido de corchetes:

    let scores: number[] = [90, 85, 78];
    let tags: string[] = ["typescript", "javascript"];
    

    La forma genérica Array<number> significa exactamente lo mismo; elija un estilo y úselo de manera consistente:

    // equivalent generic syntax
    let ids: Array<number> = [1, 2, 3];
    

    Deje que la inferencia haga el trabajo obvio

    Rara vez es necesario anotar las variables inicializadas. TypeScript infiere el tipo a partir del valor y luego lo aplica:

    let city = "Ahmedabad"; // inferred as string
    city = 42;              // Error: Type 'number' is not assignable to type 'string'
    

    Escribir let city: string = "Ahmedabad" no está mal, pero es redundante. Una buena regla para todo el lenguaje: confíe en la inferencia cuando el valor hace que el tipo sea obvio, y escriba tipos explícitos cuando un valor sea ambiguo o cuando esté definiendo un contrato, como parámetros de función, tipos de retorno, arrays vacíos y firmas de callback.

    any, unknown, never y void

    Estos cuatro tipos especiales confunden a casi todos los principiantes:

    • any desactiva la verificación de tipos para un valor. Es una solución temporal y no un tipo real; su uso excesivo es la forma más común en que una base de código termina convirtiéndose en JavaScript con sintaxis adicional.
  • unknown es la opción segura. Se le puede asignar cualquier valor, pero no se puede utilizar hasta que se determine su valor mediante una verificación. Es la elección adecuada para datos cuya estructura aún no se conoce, como una respuesta de API antes de su validación.
  • never describe un valor que no puede existir, por ejemplo, el resultado de una función que siempre lanza un error o nunca devuelve nada. Su uso más práctico es en las verificaciones de exhaustividad, permitiendo que el compilador confirme que se maneja cada caso de un switch.
  • void marca una función que no devuelve nada útil, como un manejador de eventos o un envoltorio para registro de logs.
  • Con unknown, una verificación de typeof solo desbloquea los métodos de cadena dentro del ramo protegido:

    function process(value: unknown) {
      if (typeof value === "string") {
        console.log(value.toUpperCase()); // safe — narrowed to string
      }
    }
    

    Una función que siempre lanza un error tiene el tipo never, mientras que una función que solo produce un efecto secundario devuelve void:

    function fail(message: string): never {
      throw new Error(message);
    }function logAction(action: string): void {
      console.log(`Action: ${action}`);
    }
    

    Para reemplazos concretos de any en situaciones comunes, consulte seis patrones seguros de tipo para reemplazar any.

    Describir objetos con interfaces y alias de tipo

    La estructura de un objeto puede escribirse directamente:

    const employee: { id: number; name: string; active: boolean } = {
      id: 1,
      name: "Amit",
      active: true,
    };
    

    Eso funciona una vez, pero repetir la misma estructura directa en todas partes rápidamente se convierte en ruido. Nombrar la estructura con una interface o un type resuelve este problema.

    Interfaces

    Una interface da nombre a la estructura de un objeto para que se pueda reutilizar:

    interface Employee {
      id: number;
      name: string;
      department: string;
    }
    

    Cada objeto anotado con ella debe coincidir con los campos declarados:

    const employee1: Employee = { id: 1, name: "John", department: "IT" };
    const employee2: Employee = { id: 2, name: "Sara", department: "HR" };
    

    Un signo de interrogación indica una propiedad que puede estar ausente:

    interface User {
      id: number;
      name: string;
      phone?: string; // may or may not be present
    }
    

    Las propiedades readonly pueden asignarse al crear el objeto, pero no posteriormente:

    interface Product {
      readonly id: number;
      name: string;
    }
    

    Intentar volver a asignarlas genera un error en tiempo de compilación:

    const laptop: Product = { id: 100, name: "MacBook" };
    laptop.id = 200; // Error: Cannot assign to 'id' because it is a read-only property
    

    Las interfaces también pueden extenderse entre sí, lo que permite compartir campos comunes sin copiarlos. Comience con una forma base:

    interface Person {
      name: string;
      age: number;
    }
    

    Luego cree una más específica sobre ella:

    interface Employee extends Person {
      employeeId: number;
      department: string;
    }
    

    Alias de tipo

    Un alias de type también puede dar nombre a formas de objeto, pero no está limitado a ellas. Las uniones, primitivas y tuplas también pueden recibir un nombre de esta manera:

    type ID = string | number;
    type Point = { x: number; y: number };
    type Status = "pending" | "shipped" | "delivered";
    

    Elegir entre interfaz y tipo

    Para las formas de objeto cotidianas, ambos son en gran medida intercambiables. Las verdaderas diferencias son:

    • Extensión: las interfaces utilizan extends; los alias de tipo combinan formas mediante el operador de intersección &.
    • Uniones y primitivos: solo los alias de tipo pueden expresarlos, como en type A = string | number.
    • Fusión de declaraciones: dos interfaces con el mismo nombre se fusionan en una; los alias de tipo no pueden ser redeclarados.
    • Convención: las interfaces son típicas para las formas de APIs públicas y los contratos de clases; los alias de tipo se usan para uniones, tuplas y tipos mapeados o condicionales.

    Una regla que adoptan muchos equipos: utilizar interface siempre que sea probable que una forma se extienda, como en las props de React, los modelos de API y los contratos de clases, y reservar type para uniones, tuplas y cualquier tipo que no sea objeto.

    Uniones, reducción de tipos e intersecciones

    Tipos de unión

    Una unión indica que un valor puede ser de varios tipos:

    function printId(id: string | number) {
      console.log(`Your ID is ${id}`);
    }
    

    Se aceptan ambas llamadas anteriores, ya que cada argumento coincide con un miembro de la unión:

    printId(101);
    printId("A-204");
    

    Estrechamiento

    Cuando un valor se define como una unión, TypeScript solo permite operaciones válidas para cada miembro hasta que se demuestre cuál miembro se está utilizando. Esa demostración se denomina estrechamiento, y el compilador la sigue a lo largo del flujo de control:

    function formatValue(value: string | number) {
      if (typeof value === "string") {
        return value.toUpperCase(); // TypeScript knows it's a string here
      }
      return value.toFixed(2); // and here, it knows it's a number
    }
    

    Además de typeof, las herramientas comunes de estrechamiento son Array.isArray(), instanceof, el operador in y las comprobaciones de igualdad con null o undefined. Así es como TypeScript permanece silencioso en los casos normales y, al mismo tiempo, detecta aquellos inusuales.

    Uniones discriminadas para el estado

    El patrón que los desarrolladores intermedios suelen pasar por alto es asignar a cada variante de una unión un campo literal compartido de “etiqueta”. Primero, defina cada estado por separado:

    type LoadingState = { status: "loading" };
    type SuccessState = { status: "success"; data: string[] };
    type ErrorState = { status: "error"; message: string };
    

    Luego, combínelos y utilice la etiqueta para hacer el cambio:

    type FetchState = LoadingState | SuccessState | ErrorState;function render(state: FetchState) {
      switch (state.status) {
        case "loading":
          return "Loading...";
        case "success":
          return `Loaded ${state.data.length} items`;
        case "error":
          return `Failed: ${state.message}`;
      }
    }
    

    Dentro de cada case, state se reduce a la variante correspondiente, por lo que state.data solo está disponible en la rama "success" y state.message solo en "error". Las combinaciones imposibles, como tener datos y un error al mismo tiempo, simplemente no pueden representarse. Esto elimina toda una categoría de errores de tipo “propiedad indefinida” en el código de interfaz de usuario y API. Al agregar una rama default que asigne state a una variable never, esto se convierte en una verificación de exhaustividad: si más tarde se agrega un cuarto estado, el compilador señalará cada caso que lo haya olvidado.

    Tipos de intersección

    Mientras que una unión significa “esto o aquello”, una intersección significa “esto y aquello”. Comience con dos formas simples:

    type Timestamped = { createdAt: Date };
    type Named = { name: string };
    

    Una intersección requiere que un objeto tenga los campos de ambos:

    type Record = Timestamped & Named;const item: Record = { name: "Invoice", createdAt: new Date() };
    

    Una precaución con este ejemplo: Record también es el nombre de un tipo de utilidad incorporado. Al declarar tu propio alias con ese nombre se oculta el global en ese archivo, lo cual es confuso como mínimo; por eso, en código real es mejor usar un nombre más específico.

    Funciones

    Las anotaciones de parámetros y devolución constituyen el núcleo del contrato de una función:

    function multiply(a: number, b: number): number {
      return a * b;
    }
    

    Los parámetros opcionales utilizan ?; los valores por defecto convierten a un parámetro en opcional e infieren su tipo, mientras que los parámetros rest acumulan cualquier cantidad de argumentos en un array tipado:

    // optional parameter
    function greet(name: string, title?: string): string {
      return title ? `${title} ${name}` : name;
    }// default parameter
    function createOrder(item: string, quantity: number = 1) {
      return { item, quantity };
    }// rest parameters
    function sum(...numbers: number[]): number {
      return numbers.reduce((total, n) => total + n, 0);
    }
    

    Tipos de funciones

    También puedes describir la estructura de la función en sí, lo cual es útil para callbacks y objetos de estrategia:

    type MathOperation = (a: number, b: number) => number;
    

    Una función asignada a ese tipo obtiene sus tipos de parámetro de la anotación, por lo que a y b no necesitan anotaciones propias:

    const subtract: MathOperation = (a, b) => a - b;
    

    Clases y modificadores de acceso

    Las clases de TypeScript son clases de JavaScript con propiedades tipadas y modificadores de acceso. La clase a continuación comienza declarando un saldo privado y un propietario de solo lectura:

    class Account {
      private balance: number;
      readonly owner: string;
    

    El resto de la clase establece esos campos en el constructor y expone métodos para cambiar y leer el saldo; se rechaza el acceso al campo privado desde el exterior:

      constructor(owner: string, initialBalance: number) {
        this.owner = owner;
        this.balance = initialBalance;
      }  deposit(amount: number): void {
        this.balance += amount;
      }  getBalance(): number {
        return this.balance;
      }
    }const acc = new Account("Priya", 1000);
    acc.deposit(500);
    console.log(acc.getBalance()); // 1500
    acc.balance; // Error: Property 'balance' is private
    

    Los modificadores significan:

    • public, el valor por defecto, es accesible en todas partes.
    • private solo es accesible dentro de la clase.
    • protected es accesible dentro de la clase y sus subclases.
  • readonly campos aceptan un valor en el constructor y rechazan cualquier reasignación posterior.
  • Tenga en cuenta que private solo es aplicado por el compilador; en tiempo de ejecución la propiedad es un campo ordinario. Si necesita una verdadera privacidad en tiempo de ejecución, los campos # de JavaScript la proporcionan, un compromiso abordado en Campos privados de TypeScript versus sintaxis #.

    Interfases como contratos de clase

    Una interfaz puede definir qué debe proporcionar una clase:

    interface Shape {
      area(): number;
    }
    

    Con implements, el compilador verifica que la clase cumpla con el contrato, y se informa sobre cualquier método faltante antes de que se ejecute el código. El constructor también utiliza una propiedad de parámetro, private radius, que declara y asigna el campo en un solo paso:

    class Circle implements Shape {
      constructor(private radius: number) {}  area(): number {
        return Math.PI * this.radius ** 2;
      }
    }
    

    Genéricos: código reutilizable que mantiene sus tipos

    Los genéricos son, por lo general, donde TypeScript deja de parecerse a JavaScript anotado y comienza a sentirse como una herramienta diferente.

    El problema que resuelven

    Un ayudante que devuelve el primer elemento de cualquier array puede escribirse con any:

    function firstElement(arr: any[]) {
      return arr[0];
    }
    

    Funciona, pero la información de tipo se pierde al finalizar el proceso, y cada resultado es any:

    const num = firstElement([1, 2, 3]);   // typed as `any` — no help from the compiler
    const str = firstElement(["a", "b"]);  // also `any`
    

    Un parámetro de tipo captura el tipo del elemento de la entrada y lo reutiliza para el valor de retorno:

    function firstElement<T>(arr: T[]): T {
      return arr[0];
    }
    

    Ahora cada llamada obtiene un tipo de resultado preciso, inferido a partir del argumento:

    const num = firstElement([1, 2, 3]);   // inferred as number
    const str = firstElement(["a", "b"]);  // inferred as string
    

    T es una variable de tipo que TypeScript rellena con base en lo que se pasa. Se mantiene la flexibilidad de any y se obtiene la seguridad de un tipo concreto. Tenga en cuenta que, con un array vacío, arr[0] es en realidad undefined en tiempo de ejecución; habilitar noUncheckedIndexedAccess hace que el compilador lo refleje como T | undefined.

    Interfases genéricas

    Los genericos también funcionan con las interfaces. Un único contenedor de respuestas puede describir cada endpoint:

    interface ApiResponse<T> {
      success: boolean;
      data: T;
    }
    

    El tipo de la carga útil se proporciona donde se utiliza el contenedor:

    const userResponse: ApiResponse<{ id: number; name: string }> = {
      success: true,
      data: { id: 1, name: "Sara" },
    };
    

    Así es como muchas aplicaciones tipan su capa de API: un único ApiResponse<T> reutilizado para usuarios, productos, pedidos y cualquier otra cosa que devuelva el backend.

    Restringir un parámetro de tipo

    A veces T debe garantizar ciertas propiedades. Describa el requisito como una interfaz:

    interface HasLength {
      length: number;
    }
    

    Luego restrinja el parámetro con extends, de modo que solo se acepten tipos con un length:

    function logLength<T extends HasLength>(item: T): void {
      console.log(item.length);
    }logLength("hello");       // OK — strings have .length
    logLength([1, 2, 3]);     // OK — arrays have .length
    logLength(42);             // Error: number doesn't have .length
    

    Tipos de utilidad

    TypeScript incluye tipos auxiliares genéricos que derivan nuevos tipos a partir de los existentes, reemplazando gran parte del código genérico escrito a mano. Dado un modelo de usuario:

    interface User {
      id: number;
      name: string;
      email: string;
      isAdmin: boolean;
    }
    

    Puede derivar variantes en lugar de declararlas nuevamente: Partial hace que cada propiedad sea opcional, Readonly las convierte todas en de solo lectura, Pick mantiene las claves seleccionadas, Omit las elimina y Record crea un tipo de diccionario:

    // Every property becomes optional — perfect for "update" functions
    type UserUpdate = Partial<User>;// Every property becomes read-only
    type ImmutableUser = Readonly<User>;// Pick only the fields you need
    type UserPreview = Pick<User, "id" | "name">;// Everything except the fields you list
    type PublicUser = Omit<User, "email" | "isAdmin">;// A dictionary shape: keys of one type, values of another
    type UsersById = Record<number, User>;
    

    Un uso típico es una función de actualización que acepta cualquier subconjunto de campos:

    function updateUser(id: number, changes: Partial<User>): void {
      // merge `changes` into the stored user
    }
    

    Los llamantes pasan solo lo que cambia:

    updateUser(1, { name: "New Name" }); // no need to pass email, isAdmin, etc.
    

    La guía del blog sobre los tipos de utilidad integrados de TypeScript profundiza en el conjunto completo.

    Enum y tipos literales

    Enum

    Un enum define un conjunto nombrado de constantes:

    enum OrderStatus {
      Pending,
      Shipped,
      Delivered,
    }
    

    Luego, los valores se hacen referencia a través del enum:

    let status: OrderStatus = OrderStatus.Shipped;
    

    Por defecto, los miembros están numerados a partir de 0. Asignar valores de cadena en su lugar hace que los registros y las cargas de red sean mucho más fáciles de depurar:

    enum Direction {
      Up = "UP",
      Down = "DOWN",
      Left = "LEFT",
      Right = "RIGHT",
    }
    

    Las uniones de literales de cadena suelen ser más simples

    Muchos equipos prefieren ahora una unión de literales de cadena, ya que no genera JavaScript adicional y se comporta de manera más predecible:

    type OrderStatus = "pending" | "shipped" | "delivered";
    

    El compilador sigue rechazando cualquier valor fuera del conjunto:

    function updateStatus(status: OrderStatus) {
      // ...
    }updateStatus("shipped");  // OK
    updateStatus("cancelled"); // Error: not assignable to type 'OrderStatus'
    

    Existe otra razón práctica para preferir las uniones: los enums generan código en tiempo de ejecución, por lo que las herramientas que solo eliminan los tipos, como el soporte integrado de TypeScript en Node.js, no las aceptan.

    Módulos y tsconfig.json

    Módulos

    TypeScript utiliza la sintaxis estándar de módulos ES. Un archivo exporta una función:

    // math.ts
    export function add(a: number, b: number): number {
      return a + b;
    }
    

    Otro archivo la importa:

    // app.ts
    import { add } from "./math";
    

    Los ajustes más importantes

    Una configuración generada lista docenas de opciones, pero solo unas pocas determinan en gran medida la experiencia de uso:

    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "strict": true,
        "noImplicitAny": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "outDir": "./dist"
      }
    }
    
    • strict: true habilita toda la familia de verificaciones estrictas, incluyendo strictNullChecks, que obliga a tratar explícitamente los valores null y undefined. Los desarrolladores experimentados lo consideran algo indispensable, ya que sin él TypeScript detecta muchos menos errores reales.
  • noImplicitAny reporta un error siempre que un valor de lo contrario recurriría a any sin que usted lo solicite. Ya forma parte de strict, por lo que enumerarlo por separado solo sirve para mejorar la legibilidad.
  • target determina qué versión de JavaScript se emitirá, y por ende cuánta sintaxis moderna se reescribirá para entornos más antiguos.
  • Cuando hereda una base de código legada con strict desactivado, la opción sostenible es activarlo y corregir los errores archivo por archivo, en lugar de usar any hasta que desaparezcan las líneas rojas.

    Errores comunes en cada nivel

    Principiantes

    • Anotar valores que TypeScript inferiría de todos modos.
    • Recurrir a any en cuanto el compilador emite una advertencia, en lugar de definir con mayor precisión el tipo real.
  • Descartar los errores del compilador como algo irrelevante cuando en realidad se trata de una revisión de código gratuita.
  • Nivel intermedio

    • Declarar una interface o type para una forma única que la inferencia podría manejar, lo cual añade formalidad sin aportar valor real.
    • Definir arrays como mutables y modificarlos con push o splice cuando deberían ser ReadonlyArray<T> y actualizarse de forma inmutable.
    • Olvidar que una unión debe reducirse primero para que estén disponibles los miembros específicos del tipo, y en lugar de eso luchar contra el compilador en vez de leer sus mensajes.

    Nivel avanzado

    • Escribir generics sin restricciones que podrían ser cualquier cosa, lo cual invalida silenciosamente el propósito de hacer genéricas a las funciones.
    • Deshabilitar la opción strict en todo el proyecto para ocultar algunos errores en lugar de corregirlos o delimitar su alcance.
    • Tratar una aserción de tipo como value as Type como si validara algo. Una aserción no verifica nada en tiempo de ejecución; solo le dice al compilador que confíe en usted. Los datos provenientes del exterior de su programa, incluidas las respuestas de API, la entrada del usuario y localStorage, necesitan una validación real.

    Puntos clave

    • TypeScript agrega un verificador en tiempo de compilación a JavaScript; los tipos desaparecen cuando se ejecuta el código, por lo que aún se necesitan validaciones en tiempo de ejecución.
    • Anote los contratos como las firmas de funciones y las colecciones vacías, y deje que la inferencia se encargue del resto.
    • Use interface para estructuras de objetos extensibles y type para uniones, tuplas y tipos que no son objetos.
    • Aprenda temprano sobre las uniones discriminadas; son el patrón que previene de manera más directa los errores en código con mucho estado.
    • Los tipos genéricos y de utilidad como Partial, Pick, Omit y Record son los que convierten las anotaciones dispersas en un verdadero sistema de tipos.
    • Mantenga strict: true activado. La ventaja de TypeScript es detectar los errores que de otro modo se encontrarían más tarde y a un costo mayor, y eso solo es posible cuando el comprobador puede hacer su trabajo.

    Lecturas relacionadas