Saltar al contenido
modulos-y-tsconfig.ts · devschool

Módulos y tsconfig.json en TypeScript

Lección 13 de 15 · 14 min de lectura · Actualizado el

En esta lección
  1. Qué es un módulo
  2. Exportar e importar
  3. Importar tipos: import type
  4. Extensiones en las rutas
  5. El archivo tsconfig.json
  6. Receta 1: programa para Node.js con tsc
  7. Receta 2: web con Vite
  8. Más de un tsconfig
  9. Errores frecuentes
  10. Resumen

Un programa de verdad no cabe en un solo archivo. Se reparte en módulos: archivos pequeños que se pasan entre sí lo que necesitan. En esta lección verás cómo exportar e importar con TypeScript (incluidos los tipos, que se importan de forma especial), qué es exactamente el tsconfig.json y cómo configurarlo para dos situaciones muy habituales: un programa para Node.js que compilas con tsc y una web con Vite.

Si no has usado antes módulos en JavaScript, empieza por la lección de módulos de JavaScript: la sintaxis es la misma, TypeScript solo añade los tipos.

Qué es un módulo

En TypeScript, un archivo es un módulo en cuanto tiene al menos un import o un export. Si no tiene ninguno, es un script y todo lo que declara va al ámbito global, compartido con el resto de scripts del proyecto. Se nota enseguida:

// a.ts
const nombre = "Irene";

// b.ts
const nombre = "Pablo";
// error TS2451: Cannot redeclare block-scoped variable 'nombre'.

Los dos archivos declaran la misma variable global y chocan. Si añades export {}; a cada archivo (una exportación vacía), pasan a ser módulos y cada nombre es privado de su archivo. Es un truco muy común en ejemplos sueltos. En un proyecto real casi todos tus archivos exportan algo, así que no hace falta.

Exportar e importar

Lo que quieras compartir lleva export:

// src/socio.ts
export interface Socio {
  id: number;
  nombre: string;
  cuota: number;
}

export const CUOTA_BASE = 35;

export function crearSocio(id: number, nombre: string, cuota = CUOTA_BASE): Socio {
  return { id, nombre, cuota };
}

Y se importa entre llaves, con el mismo nombre:

// src/main.ts
import { crearSocio, CUOTA_BASE } from "./socio.js";

const irene = crearSocio(1, "Irene");
const pablo = crearSocio(2, "Pablo", CUOTA_BASE + 5);
console.log(irene.cuota + pablo.cuota); // 75

Fíjate en que Socio también se exporta con export, igual que una función o una constante: las interfaces y los alias de tipo se comparten igual que los valores.

El .js del final de la ruta puede extrañarte, porque el archivo se llama socio.ts. Lo explico más abajo, en la sección sobre extensiones.

Importar con otro nombre o todo junto

import { crearSocio as nuevoSocio } from "./socio.js";
import * as socio from "./socio.js";

console.log(nuevoSocio(3, "Lucía").nombre); // Lucía
console.log(socio.CUOTA_BASE);              // 35
  • as renombra lo importado, útil si dos módulos exportan el mismo nombre.
  • import * as socio trae todo el módulo como un objeto: socio.crearSocio, socio.CUOTA_BASE…

Exportación por defecto

Un módulo puede tener una exportación por defecto (default), que se importa sin llaves y con el nombre que quieras:

// src/registro.ts
import type { Socio } from "./socio.js";

export default class Registro {
  #socios: Socio[] = [];

  anadir(socio: Socio): void {
    this.#socios.push(socio);
  }

  get total(): number {
    return this.#socios.reduce((suma, s) => suma + s.cuota, 0);
  }
}
// src/main.ts
import Registro from "./registro.js";

const registro = new Registro();

Consejo: prefiere las exportaciones con nombre (export function ...). El editor autocompleta mejor, el nombre es el mismo en todo el proyecto y renombrar es más fácil. Deja default para cuando una herramienta lo pida, o para un archivo que solo contiene una clase o un componente.

Se puede combinar: import Registro, { crearSocio } from .... También se puede reexportar desde un archivo “índice” con export { crearSocio } from "./socio.js";, para que otros importen desde un solo sitio.

Importar tipos: import type

Los tipos solo existen mientras compila TypeScript: al generar el JavaScript desaparecen. Por eso conviene dejar claro qué importas solo como tipo, con import type:

import type { Socio } from "./socio.js";
import { crearSocio, type Cuota } from "./socio.js";
  • import type { Socio } importa únicamente tipos. Esa línea se borra entera del JavaScript final.
  • { crearSocio, type Cuota } mezcla un valor y un tipo en la misma línea. Solo desaparece Cuota.

También existe export type { Socio } para exportar solo tipos.

¿Por qué molestarse? Porque herramientas como Vite, esbuild o el propio Node (que borran los tipos sin entenderlos del todo) no pueden saber si Socio es un tipo o un valor, y necesitan que se lo indiques para decidir si borrar el import. Para obligarte a hacerlo bien, se activa la opción verbatimModuleSyntax en el tsconfig.json: con ella, importar un tipo sin type es un error.

import { crearSocio, Socio } from "./socio.js";
// error TS1484: 'Socio' is a type and must be imported using a type-only import
// when 'verbatimModuleSyntax' is enabled.

El arreglo es siempre el mismo: escribe type Socio en el import.

Extensiones en las rutas

Las rutas relativas empiezan por ./ o ../. La cuestión incómoda es qué extensión poner, y depende de quién ejecuta el código:

EntornoCómo escribir la rutamoduleResolution
Node.js compilado con tsc"./socio.js" (aunque el archivo sea .ts)nodenext
Node.js ejecutando .ts directamente"./socio.ts"nodenext + rewriteRelativeImportExtensions (si compilas)
Vite y otros empaquetadores"./socio" (sin extensión)bundler
  • En Node.js los módulos ES exigen la extensión. Como tsc no reescribe las rutas, tienes que escribir ya la que tendrá el archivo después de compilar: .js. Parece raro, pero es lo que va a existir en dist/. Si pones "./socio" sin extensión, TypeScript te lo avisa con el error TS2835 y te sugiere ./socio.js.
  • Si ejecutas los .ts directamente con Node (type stripping, lo viste en Instalar y compilar), la ruta apunta al archivo real, .ts. TypeScript solo la permite si activas allowImportingTsExtensions (cuando no generas archivos) o rewriteRelativeImportExtensions, que además cambia .ts por .js en el resultado.
  • Con Vite el empaquetador resuelve las rutas por ti, así que puedes omitir la extensión.

Para que Node trate tus .js como módulos ES, el package.json debe incluir "type": "module". Sin esa línea, Node espera la sintaxis antigua (require).

El archivo tsconfig.json

Ya conoces tsconfig.json de la lección de instalación: es el archivo de la raíz del proyecto que le dice a tsc (y al editor) cómo comprobar y compilar. Está dividido en dos zonas:

{
  "compilerOptions": {
    "...": "opciones del compilador"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
  • compilerOptions: cómo se comprueba y qué JavaScript se genera.
  • include y exclude: qué archivos forman el proyecto. Si no pones include, se usan todos los .ts de la carpeta, incluidos los de dist, lo que suele liar. Indica siempre "include": ["src"].

Los comentarios están permitidos en este archivo, aunque se llame .json.

Opciones que debes conocer

OpciónQué hace
strictActiva todas las comprobaciones estrictas. Siempre true
targetVersión de JavaScript generada: es2022, es2020… Los navegadores y Node actuales aceptan es2022 sin problema
moduleSistema de módulos del resultado: nodenext para Node, esnext para empaquetadores
moduleResolutionCómo se buscan los archivos importados: nodenext o bundler
rootDirCarpeta raíz de tus fuentes. Se copia su estructura a outDir
outDirCarpeta donde se escribe el JavaScript
noEmitNo genera archivos: solo comprueba tipos
libQué tipos del entorno hay disponibles: es2022, dom…
typesQué paquetes de tipos globales se cargan, por ejemplo ["node"]
verbatimModuleSyntaxObliga a usar import type para los tipos
skipLibCheckNo revisa los tipos de node_modules: más rápido
sourceMapGenera .js.map para depurar el .ts original
noEmitOnErrorNo escribe nada si hay errores de tipos
noUncheckedIndexedAccessUn acceso lista[i] da T | undefined
noImplicitOverrideObliga a escribir override al sobrescribir un método de la clase padre
erasableSyntaxOnlyProhíbe la sintaxis que genera código al compilar (enums, parameter properties)

Dos de ellas merecen una nota:

  • rootDir y outDir trabajan juntas. Con rootDir: "src" y outDir: "dist", el archivo src/utiles/fechas.ts se convierte en dist/utiles/fechas.js. Si no pones rootDir, TypeScript lo deduce, pero es mejor decirlo para que dist no cambie de forma al añadir archivos.
  • noUncheckedIndexedAccess no está dentro de strict, pero muchos equipos la activan. Con ella, const primero = lista[0] es string | undefined y te obliga a comprobar que existe. Es más incómoda y más segura.

Nota: en TypeScript 6, strict ya viene activado por defecto y hay opciones nuevas por defecto, como types: [] (ya no se cargan automáticamente los paquetes de @types). En TypeScript 5 era distinto. Por eso conviene escribir las opciones importantes de forma explícita: tu proyecto funciona igual con cualquier versión.

Un caso típico: tipos de Node.js

Si usas process, node:fs o cualquier cosa propia de Node.js, TypeScript necesita sus tipos. Se instalan como dependencia de desarrollo y se declaran en types:

npm install -D @types/node
{
  "compilerOptions": {
    "types": ["node"]
  }
}

Sin ellos verás un error como Cannot find name 'process'. Do you need to install type definitions for node?. Al contrario, en un proyecto de navegador no debes poner node, así process no te dejará usarlo donde no existe.

Receta 1: programa para Node.js con tsc

Este es el proyecto del club de socios de las secciones anteriores:

club/
├── package.json
├── tsconfig.json
├── src/
│   ├── main.ts
│   ├── registro.ts
│   └── socio.ts
└── dist/          ← lo genera tsc

package.json:

{
  "name": "club",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "start": "node dist/main.js"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.9.0"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "types": ["node"]
  },
  "include": ["src"]
}

Ahora el trabajo diario son dos comandos:

npm run build
npm start

npm run build ejecuta tsc, que lee el tsconfig.json, comprueba los tipos y escribe el JavaScript en dist/. npm start ejecuta el resultado con Node.js:

75

Mientras programas, usa el modo vigilancia en una terminal aparte:

npm run dev
8:26:07 PM - Starting compilation in watch mode...
8:26:08 PM - Found 0 errors. Watching for file changes.
8:26:11 PM - File change detected. Starting incremental compilation...
src/socio.ts(24,14): error TS2322: Type 'string' is not assignable to type 'number'.
8:26:11 PM - Found 1 error. Watching for file changes.

Cada vez que guardas un archivo, tsc recompila solo lo que cambió y muestra los errores. Se detiene con Ctrl + C.

Algunas variantes útiles del comando:

ComandoQué hace
npx tscCompila el proyecto según tsconfig.json
npx tsc --watchRecompila al guardar
npx tsc --noEmitSolo comprueba, no escribe archivos
npx tsc -p tsconfig.build.jsonUsa otro archivo de configuración
npx tsc --showConfigMuestra la configuración final que se está usando

Cuidado: tsc escribe los .js aunque haya errores de tipos. Si no quieres que se genere nada con errores, activa noEmitOnError. Y recuerda añadir dist y node_modules al .gitignore.

Receta 2: web con Vite

En un proyecto Vite el empaquetador se encarga de generar el JavaScript, así que tsc solo se usa para comprobar tipos. Por eso la configuración cambia:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": ["vite/client"],
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Qué ha cambiado respecto a la receta de Node.js:

  • module: "esnext" y moduleResolution: "bundler": Vite entiende módulos ES y resuelve las rutas, así que escribes import { crearSocio } from "./socio" sin extensión.
  • lib incluye dom: en el navegador sí existe document.
  • noEmit: true: Vite genera el código, tsc solo vigila los tipos. No hay outDir.
  • types: ["vite/client"]: tipos de las funciones que añade Vite (como importar imágenes o import.meta.env).

El package.json de las plantillas de Vite tiene scripts como estos:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  }
}

npm run dev levanta el servidor de desarrollo (que no comprueba tipos, solo los borra), y npm run build ejecuta primero tsc y, si no hay errores, vite build. Para ver los errores mientras desarrollas, abre otra terminal con npx tsc --noEmit --watch, además de los subrayados del editor.

Más de un tsconfig

En proyectos grandes verás varios archivos de configuración. Se pueden heredar unos de otros con extends:

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

Este archivo (por ejemplo tsconfig.build.json) reutiliza todo el original y cambia lo que haga falta, como excluir los archivos de pruebas del resultado final. Se usa con npx tsc -p tsconfig.build.json.

Errores frecuentes

  • Relative import paths need explicit file extensions: en un proyecto Node (nodenext) tienes que escribir "./socio.js". Sin extensión solo funciona con moduleResolution: "bundler".
  • Escribir .ts en la ruta de import y que no compile: TypeScript solo lo admite con allowImportingTsExtensions o rewriteRelativeImportExtensions. Con tsc clásico escribe .js.
  • Cannot redeclare block-scoped variable: tus archivos no tienen import ni export, así que son scripts y comparten el ámbito global. Convierte cada uno en módulo.
  • 'Socio' is a type and must be imported using a type-only import: con verbatimModuleSyntax, escribe import type { Socio } o import { type Socio }.
  • Cannot find name 'process': faltan los tipos de Node. Instala @types/node y añade "types": ["node"].
  • Olvidar "type": "module" en el package.json: Node intentará leer tus módulos como CommonJS y dará errores con import.
  • No poner include: tsc puede compilar archivos que no querías, como los de dist o los de pruebas.
  • Desactivar strict para “arreglar” errores: los errores siguen ahí, solo dejas de verlos.

Resumen

ConceptoEjemplo
Exportarexport function f() {}, export interface A {}
Importarimport { f } from "./modulo.js"
Renombrarimport { f as g } from ...
Todo el móduloimport * as m from "./modulo.js"
Por defectoexport default class A {} e import A from ...
Solo tiposimport type { A } from ..., import { f, type A } from ...
Módulo o scriptCon import/export es módulo; sin ellos, ámbito global
Proyecto Nodemodule: "nodenext", rootDir, outDir, rutas con .js
Proyecto Vitemodule: "esnext", moduleResolution: "bundler", noEmit: true
Siempre"strict": true e "include": ["src"]
Compilarnpx tsc, npx tsc --watch, npx tsc --noEmit
Tipos de Nodenpm i -D @types/node y "types": ["node"]

En la siguiente lección usarás todo esto para trabajar con la página: TypeScript y el DOM.

Pon a prueba lo que has aprendido

[TypeScript] Dos archivos tienen const nombre = ".." y ninguno tiene import ni export. ¿Qué pasa?

[TypeScript] Proyecto Node compilado con tsc y module nodenext: ¿cómo importas ./socio.ts?

[TypeScript] Con verbatimModuleSyntax activado, ¿qué error da esta línea y cómo se arregla?
import { crearSocio, Socio } from "./socio.js";

[TypeScript] ¿Por qué la configuración típica de un proyecto Vite lleva "noEmit": true?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.