Configurar tsconfig para un nuevo proyecto TypeScript

1. Descripción general

El archivo tsconfig.json determina cómo TypeScript verifica tipos y compila código. Si lo configuras incorrectamente, puedes encontrar errores de importación en tiempo de ejecución o el compilador puede perder muchos errores de tipo.

Este artículo construye un tsconfig.json para un nuevo proyecto Node.js desde cero. Activaremos banderas de verificación estricta, elegiremos el sistema de módulos correcto y separaremos la configuración de build de la configuración de pruebas. Al terminarlo, tendrás una configuración lista para usar y comprenderás cada línea.

2. Inicialización del proyecto

Crea un directorio e instala TypeScript como dependencia de desarrollo:

mkdir order-service && cd order-service
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init

El comando tsc --init genera un tsconfig.json con muchas líneas de comentarios. Este archivo es útil como referencia, pero difícil de mantener. Lo reemplazaremos con una versión más concisa que documente cada opción claramente.

A continuación, declara que el proyecto usa módulos ES en package.json:

{
  "name": "order-service",
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js"
  }
}

La línea "type": "module" le dice a Node.js que los archivos .js son ESM. Esta configuración afecta directamente cómo TypeScript resuelve módulos, como verás en la sección 3.

3. Configuración mínima para Node.js

Esta es la configuración base de tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],

    "rootDir": ".",
    "outDir": "dist",
    "sourceMap": true,
    "declaration": false,

    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "tests"]
}

3.1. target, lib y types

  • target es la versión de JavaScript que el compilador genera. ES2022 es compatible con Node.js 18 en adelante. Con este target, async/await, optional chaining y class fields se mantienen sin cambios, sin ser transpilados.
  • lib declara qué APIs están disponibles en tiempo de ejecución. Sin "DOM", evitarás llamar accidentalmente a document o window en código de servidor.
  • types: ["node"] carga solo los tipos de Node.js. Sin declarar esto, TypeScript carga automáticamente todos los paquetes en node_modules/@types, lo que a veces introduce tipos globales no deseados.

3.2. module y moduleResolution

Para un proyecto que se ejecuta directamente en Node.js, usa NodeNext para ambas opciones. En este modo, TypeScript simula correctamente cómo Node.js resuelve importaciones. Lee los campos "type" y "exports" en package.json y requiere que las importaciones relativas tengan extensión de archivo.

Supongamos que tienes dos archivos:

// src/order.ts
export interface Order {
  id: string;
  items: string[];
  note?: string;
}

export function totalItems(order: Order): number {
  return order.items.length;
}
// src/index.ts
import { totalItems } from "./order";

console.log(totalItems({ id: "A1", items: ["pho", "tra-da"] }));

Ejecutar npx tsc --noEmit reportará un error:

src/index.ts:1:28 - error TS2835: Relative import paths need explicit file extensions
in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './order.js'?

Necesitas cambiar a import { totalItems } from "./order.js";. La extensión es .js y no .ts porque Node.js lee el archivo compilado en tiempo de ejecución. Esta regla puede parecer extraña al principio, pero te ayuda a evitar errores ERR_MODULE_NOT_FOUND en producción.

3.3. rootDir y outDir

outDir es dónde van los archivos .js después de construir. rootDir determina la estructura de directorios dentro de outDir. En la configuración base, rootDir es "." porque include contiene tanto src como tests. En la sección 6, el archivo de build cambiará rootDir a src, para que dist/index.js esté en el lugar correcto en lugar de estar en dist/src/index.js.

4. Strict mode y banderas de verificación adicionales

La bandera strict: true activa un grupo de verificaciones que incluyen strictNullChecks, noImplicitAny y strictFunctionTypes. Para un proyecto nuevo, siempre debes activar esta bandera. Habilitarla más tarde en una base de código grande requerirá mucho más esfuerzo.

Además de strict, considera agregar estas banderas:

{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Combina estas líneas en el bloque compilerOptions de tu configuración base. Las dos primeras banderas merecen una explicación más detallada.

4.1. noUncheckedIndexedAccess

Por defecto, TypeScript asume que un elemento obtenido por índice siempre existe. En realidad, eso no es el caso:

import type { Order } from "./order.js";

const ordersById: Record<string, Order> = {};

const order = ordersById["A1"];
console.log(order.id);

Cuando activas esta bandera, el tipo de order es Order | undefined, y el compilador reporta:

error TS18048: 'order' is possibly 'undefined'.

Te obliga a verificar antes de usar: if (order) { console.log(order.id); }. Sin esta bandera, el código anterior se compila pero lanza un TypeError en tiempo de ejecución.

4.2. exactOptionalPropertyTypes

Esta bandera distingue entre ‘propiedad no presente’ y ‘propiedad con valor undefined’:

import type { Order } from "./order.js";

const order: Order = { id: "A2", items: [], note: undefined };
error TS2375: Type '{ id: string; items: never[]; note: undefined; }' is not assignable
to type 'Order' with 'exactOptionalPropertyTypes: true'.

Esta distinción es importante cuando usas Object.keys, el operador in o serializas a JSON. Si realmente quieres permitir undefined, decláralo explícitamente: note?: string | undefined.

Nota: exactOptionalPropertyTypes a veces entra en conflicto con tipos de bibliotecas de terceros, especialmente librerías de manejo de formularios y ORMs. Si esta bandera causa demasiados errores fuera de tu control, desactívala. Sin embargo, es recomendable mantener noUncheckedIndexedAccess.

5. Interacción entre módulos

Agrega estas dos banderas a compilerOptions:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}

verbatimModuleSyntax tiene una regla simple: los imports sin la palabra type se mantienen tal cual en la salida, mientras que los imports con type se eliminan. Esto hace que el resultado compilado sea predecible. A cambio, debes marcar explícitamente los imports que se usan solo para tipos:

// Incorrecto: Order es solo una interface
import { Order } from "./order.js";
error TS1484: 'Order' is a type and must be imported using a type-only import
when 'verbatimModuleSyntax' is enabled.
// Correcto
import type { Order } from "./order.js";
import { totalItems } from "./order.js";

isolatedModules asegura que cada archivo pueda compilarse de forma independiente. Herramientas como esbuild, swc y Vitest procesan cada archivo por separado, por lo que esta bandera te ayuda a detectar errores antes de que causen problemas.

Quizás hayas visto esModuleInterop en muchas guías antiguas. Con module: NodeNext, esta bandera está habilitada por defecto, así que no necesitas declararla explícitamente.

6. Separar configuración typecheck y build

El archivo base tsconfig.json sirve al editor y al comando typecheck. Incluye tests. Cuando haces build, no quieres incluir archivos de prueba en dist. Por lo tanto, crea un archivo adicional tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "noEmit": false
  },
  "include": ["src"],
  "exclude": ["**/*.test.ts"]
}

extends hereda todas las compilerOptions del archivo base. En el archivo hijo, include y exclude reemplazan completamente los valores del archivo padre, no se fusionan. Ejecuta el comando de compilación:

npm run build
ls dist
index.js  index.js.map  order.js  order.js.map

El directorio dist solo contiene código de aplicación, ubicado en la raíz gracias a rootDir: "src".

6.1. Escribir pruebas con Vitest

Instala Vitest:

npm install --save-dev vitest

Agrega "test": "vitest run" a scripts y luego escribe pruebas para la función totalItems:

// tests/order.test.ts
import { describe, expect, it } from "vitest";
import type { Order } from "../src/order.js";
import { totalItems } from "../src/order.js";

describe("totalItems", () => {
  it("cuenta correctamente los artículos en una orden", () => {
    const order: Order = { id: "A1", items: ["pho", "tra-da", "quay"] };
    expect(totalItems(order)).toBe(3);
  });

  it("retorna 0 para una orden vacía", () => {
    expect(totalItems({ id: "A2", items: [] })).toBe(0);
  });
});
npm run typecheck && npm test
 ✓ tests/order.test.ts (2 tests)
 Test Files  1 passed (1)
      Tests  2 passed (2)

Vitest elimina automáticamente tipos cuando se ejecuta sin verificarlos. Por lo tanto, siempre ejecuta typecheck en CI. De lo contrario, una prueba con errores de tipo aún podría ‘pasar’.

7. Variante para proyectos con empaquetador

Si tu código está empaquetado por Vite, esbuild o webpack en lugar de ejecutarse directamente en Node.js, cambia la sección de módulos así:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true,
    "jsx": "react-jsx"
  }
}
  • moduleResolution: "Bundler" permite importaciones sin extensión de archivo porque el empaquetador resuelve automáticamente.
  • noEmit: true significa que tsc solo verifica tipos, mientras que el empaquetador genera los archivos.
  • Agrega "DOM" porque el código se ejecuta en el navegador. La línea jsx solo es necesaria si usas React.

Nota: No uses Bundler para código que se ejecuta directamente con node dist/index.js. El compilador aceptará importaciones sin la extensión .js, pero Node.js reportará un error en tiempo de ejecución.

8. Errores comunes

Usar paths para crear alias y luego compilar con tsc. Supongamos que declaras "paths": { "@/*": ["src/*"] }. El editor entiende este alias, pero tsc no reescribe las rutas en la salida, por lo que node no encontrará @/order.js. Para un proyecto Node.js, usa subpath imports en package.json:

{
  "imports": {
    "#src/*": "./dist/*"
  }
}

Luego importas con import { totalItems } from "#src/order.js";. En modo NodeNext, TypeScript lee el campo imports y mapea de vuelta desde dist a src, por lo que tanto el editor como el runtime resuelven correctamente.

Abusar de skipLibCheck. Esta bandera solo omite la verificación de archivos .d.ts, lo que hace que la compilación sea más rápida y evita errores de tipos de bibliotecas. No compromete la seguridad de tu código. Sin embargo, si escribes tus propios archivos .d.ts, recuerda que los errores en ellos también se ignorarán.

Olvidar include. Sin include, TypeScript escanea el directorio completo, incluyendo dist. Esto generalmente resulta en el error TS5055: Cannot write file ... because it would overwrite input file.

Mezclar CommonJS y ESM. Si package.json no tiene "type": "module" pero usas NodeNext, los archivos .ts se compilarán a CommonJS. Entonces, importar un paquete que solo soporta ESM causará un error. Elige una dirección desde el principio. Para un proyecto nuevo, elige ESM.

Habilitar strict y luego esparcir any por todas partes. Hacer esto anula el propósito del strict mode. Cuando no conozcas el tipo de datos, usa unknown y luego reduce el tipo con sentencias de verificación.

9. Conclusión

Este artículo construyó un tsconfig.json para un nuevo proyecto Node.js que incluye lo siguiente:

  • module y moduleResolution establecidas en NodeNext, junto con "type": "module" para que el compilador simule correctamente el runtime.
  • strict junto con las banderas noUncheckedIndexedAccess y exactOptionalPropertyTypes para detectar errores tempranamente.
  • verbatimModuleSyntax e isolatedModules para hacer la salida predecible y compatible con herramientas de compilación rápida.
  • Archivo tsconfig.build.json separado, para que dist solo contenga código de aplicación.

Si el proyecto usa bundler, solo necesitas cambiar a moduleResolution: "Bundler" y noEmit: true. Las banderas de verificación estricta se mantienen. Finalmente, siempre ejecuta tsc --noEmit en CI, ya que la mayoría de herramientas de prueba y empaquetadores no verifican tipos por ti.

typescripttsconfignodejsconfiguraciónvitest