Módulos y tsconfig.json en TypeScript
En esta lección
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
asrenombra lo importado, útil si dos módulos exportan el mismo nombre.import * as sociotrae 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. Dejadefaultpara 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 desapareceCuota.
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:
| Entorno | Cómo escribir la ruta | moduleResolution |
|---|---|---|
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
tscno 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 endist/. Si pones"./socio"sin extensión, TypeScript te lo avisa con el errorTS2835y te sugiere./socio.js. - Si ejecutas los
.tsdirectamente con Node (type stripping, lo viste en Instalar y compilar), la ruta apunta al archivo real,.ts. TypeScript solo la permite si activasallowImportingTsExtensions(cuando no generas archivos) orewriteRelativeImportExtensions, que además cambia.tspor.jsen 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.includeyexclude: qué archivos forman el proyecto. Si no ponesinclude, se usan todos los.tsde la carpeta, incluidos los dedist, 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ón | Qué hace |
|---|---|
strict | Activa todas las comprobaciones estrictas. Siempre true |
target | Versión de JavaScript generada: es2022, es2020… Los navegadores y Node actuales aceptan es2022 sin problema |
module | Sistema de módulos del resultado: nodenext para Node, esnext para empaquetadores |
moduleResolution | Cómo se buscan los archivos importados: nodenext o bundler |
rootDir | Carpeta raíz de tus fuentes. Se copia su estructura a outDir |
outDir | Carpeta donde se escribe el JavaScript |
noEmit | No genera archivos: solo comprueba tipos |
lib | Qué tipos del entorno hay disponibles: es2022, dom… |
types | Qué paquetes de tipos globales se cargan, por ejemplo ["node"] |
verbatimModuleSyntax | Obliga a usar import type para los tipos |
skipLibCheck | No revisa los tipos de node_modules: más rápido |
sourceMap | Genera .js.map para depurar el .ts original |
noEmitOnError | No escribe nada si hay errores de tipos |
noUncheckedIndexedAccess | Un acceso lista[i] da T | undefined |
noImplicitOverride | Obliga a escribir override al sobrescribir un método de la clase padre |
erasableSyntaxOnly | Prohíbe la sintaxis que genera código al compilar (enums, parameter properties) |
Dos de ellas merecen una nota:
rootDiryoutDirtrabajan juntas. ConrootDir: "src"youtDir: "dist", el archivosrc/utiles/fechas.tsse convierte endist/utiles/fechas.js. Si no ponesrootDir, TypeScript lo deduce, pero es mejor decirlo para quedistno cambie de forma al añadir archivos.noUncheckedIndexedAccessno está dentro destrict, pero muchos equipos la activan. Con ella,const primero = lista[0]esstring | undefinedy te obliga a comprobar que existe. Es más incómoda y más segura.
Nota: en TypeScript 6,
strictya viene activado por defecto y hay opciones nuevas por defecto, comotypes: [](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:
| Comando | Qué hace |
|---|---|
npx tsc | Compila el proyecto según tsconfig.json |
npx tsc --watch | Recompila al guardar |
npx tsc --noEmit | Solo comprueba, no escribe archivos |
npx tsc -p tsconfig.build.json | Usa otro archivo de configuración |
npx tsc --showConfig | Muestra la configuración final que se está usando |
Cuidado:
tscescribe los.jsaunque haya errores de tipos. Si no quieres que se genere nada con errores, activanoEmitOnError. Y recuerda añadirdistynode_modulesal.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"ymoduleResolution: "bundler": Vite entiende módulos ES y resuelve las rutas, así que escribesimport { crearSocio } from "./socio"sin extensión.libincluyedom: en el navegador sí existedocument.noEmit: true: Vite genera el código,tscsolo vigila los tipos. No hayoutDir.types: ["vite/client"]: tipos de las funciones que añade Vite (como importar imágenes oimport.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 conmoduleResolution: "bundler".- Escribir
.tsen la ruta de import y que no compile: TypeScript solo lo admite conallowImportingTsExtensionsorewriteRelativeImportExtensions. Contscclásico escribe.js. Cannot redeclare block-scoped variable: tus archivos no tienenimportniexport, 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: converbatimModuleSyntax, escribeimport type { Socio }oimport { type Socio }.Cannot find name 'process': faltan los tipos de Node. Instala@types/nodey añade"types": ["node"].- Olvidar
"type": "module"en elpackage.json: Node intentará leer tus módulos como CommonJS y dará errores conimport. - No poner
include:tscpuede compilar archivos que no querías, como los dedisto los de pruebas. - Desactivar
strictpara “arreglar” errores: los errores siguen ahí, solo dejas de verlos.
Resumen
| Concepto | Ejemplo |
|---|---|
| Exportar | export function f() {}, export interface A {} |
| Importar | import { f } from "./modulo.js" |
| Renombrar | import { f as g } from ... |
| Todo el módulo | import * as m from "./modulo.js" |
| Por defecto | export default class A {} e import A from ... |
| Solo tipos | import type { A } from ..., import { f, type A } from ... |
| Módulo o script | Con import/export es módulo; sin ellos, ámbito global |
| Proyecto Node | module: "nodenext", rootDir, outDir, rutas con .js |
| Proyecto Vite | module: "esnext", moduleResolution: "bundler", noEmit: true |
| Siempre | "strict": true e "include": ["src"] |
| Compilar | npx tsc, npx tsc --watch, npx tsc --noEmit |
| Tipos de Node | npm 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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.