Subpath imports: alias privados con #
El campo imports: alias internos que solo el propio paquete ve, para rutas limpias, intercambio condicional de implementaciones y dependencias privadas, todo con soporte nativo del runtime.
Si exports define la puerta pública del paquete, imports define sus pasillos privados. Los subpath imports —especificadores que empiezan por #— son alias que solo funcionan dentro del paquete que los declara. Resuelven de raíz el viejo dolor de los ../../../utils, permiten intercambiar implementaciones según el entorno y todo ello sin un bundler: es el runtime quien los entiende. Son la pieza que faltaba para tener alias internos que funcionan igual en Node, en el navegador y en el type-checker.
- Definir alias internos con el prefijo
#en el campoimports. - Diferenciar
imports(privado) deexports(público). - Usar conditions dentro de
importspara intercambiar implementaciones. - Sustituir los tsconfig paths por un mecanismo portable y nativo del runtime.
El campo imports
Junto a exports, el package.json admite un campo hermano: imports. Mapea alias que empiezan por # a rutas internas del paquete. La diferencia clave es de audiencia: exports lo consumen otros; imports lo consume solo el propio paquete.
{
"imports": {
"#utils/*": "./src/utils/*.js",
"#config": "./src/config.js"
}
}
Con eso, cualquier archivo del paquete puede escribir import x from "#config" y resolverá a ./src/config.js, sin importar a qué profundidad esté. Los ../../ desaparecen y las rutas dejan de romperse al mover un archivo de carpeta.
Un alias # no tiene por qué apuntar a un archivo interno: puede apuntar a una dependencia. Esto crea una indirección privada muy útil, la de poder cambiar la implementación de algo sin tocar cada import:
{
"imports": {
"#logger": "pino",
"#test-utils": "./test/helpers.js"
}
}
Si mañana cambias de librería de logging, editas una sola línea del imports en lugar de cientos de imports repartidos por el código. El alias se convierte en el punto único de cambio, un nivel de indirección que el runtime entiende sin ayuda de nadie.
Los dos campos dividen el mundo en dos audiencias que no se solapan, y ese es todo el modelo mental que necesitas:
flowchart LR EXT[otro paquete cualquiera] -->|solo alcanza| EXP[campo exports] INT[archivos del propio paquete] -->|alcanzan| EXP INT -->|alcanzan ademas| IMP[campo imports con hash] EXP --> PUB[superficie publica] IMP --> PRV[alias privados internos]
Como en exports, los patrones también valen aquí: una sola entrada "#lib/*": "./src/lib/*.js" crea toda una familia de alias privados. Y el valor puede ser condicional y de fallback al mismo tiempo, combinando lo visto: un objeto de conditions cuyas ramas son arrays de rutas. La expresividad de imports es exactamente la de exports, solo que apuntando hacia dentro del paquete en lugar de hacia fuera.
Por qué el prefijo hash
El # no es decorativo: es la señal sintáctica que le dice al runtime “esto es un alias privado de ESTE paquete, resuélvelo con imports y no busques en node_modules”. Un especificador desnudo como utils dispararía el ascenso por node_modules de la lección 1; #utils nunca sale del paquete. Es lo que garantiza que el mecanismo sea encapsulado de verdad.
Esa privacidad es una garantía, no una convención. Aunque publiques el paquete en npm, nadie externo puede alcanzar tus alias #: no forman parte de tu superficie pública, que sigue definiendo solo exports. Tienes así dos mapas complementarios en el mismo package.json —exports para el mundo, imports para ti— y ninguno filtra en el otro.
La simetría con exports es total: igual que un subpath público no listado no existe para el exterior, un especificador # no declarado en imports lanza un error de resolución. No hay alias implícitos. Esa exigencia de declararlo todo es precisamente lo que hace el mecanismo predecible: lo que ves en el imports es exactamente el conjunto de alias privados que existen, ni uno más ni uno menos.
Estrictamente privado
Ningún otro paquete puede escribir mi-lib#config. Los alias de imports no existen fuera de su propio paquete.
Sin ascenso
El # corta el algoritmo de node_modules: se resuelve solo contra el imports local, nunca contra dependencias.
A prueba de mudanzas
Como el alias es estable, mover archivos internos no rompe ningún import: solo actualizas el mapa en un sitio.
Intercambio condicional
El campo imports admite conditions exactamente igual que exports. Su caso estrella es servir una implementación distinta según el entorno desde un único import interno, el patrón polyfill o ponyfill sin plugins ni alias de bundler:
{
"imports": {
"#crypto": {
"node": "./src/crypto-node.js",
"browser": "./src/crypto-browser.js",
"default": "./src/crypto-fallback.js"
}
}
}
El código escribe siempre import { hash } from "#crypto" y es el entorno quien elige la variante: la de Node cuando corre en el servidor, la de navegador cuando la empaqueta un bundler para el cliente. Un solo import, tres implementaciones, cero condicionales en tu código. Y como es estándar, funciona igual en Node, Deno, Bun y cualquier bundler que respete la especificación.
El intercambio no se limita a node y browser. Combinando imports con una condition personalizada activada solo en los tests, puedes sustituir una dependencia real por un doble sin tocar el código ni configurar el runner: #db apunta a la implementación real por defecto y a un mock bajo la condition test. Es inyección de dependencias resuelta por el runtime, sin framework ni contenedor de por medio.
Tanto imports como exports aceptan un array como valor: el runtime prueba las rutas en orden y se queda con la primera que exista. Es ideal para degradar con elegancia, por ejemplo intentar una implementación nativa optimizada y caer en una de JavaScript puro cuando no esté disponible. El resultado es un import que se adapta al entorno sin una sola línea de lógica condicional en tu propio código.
imports frente a tsconfig paths y alias de bundler
Durante años, crear un alias interno exigía mantener tres ficciones paralelas que se rompían la una sin la otra. paths en el tsconfig servía solo para el type-checker y era invisible en runtime: TypeScript resolvía el alias, pero al ejecutar el JavaScript compilado, Node no sabía nada de él. resolve.alias en webpack o Vite existía solo durante el build, no en node script.js. Y moduleNameMapper en Jest era una tercera copia solo para los tests. Tres fuentes de verdad que había que sincronizar a mano y que se desincronizaban a la primera de cambio, produciendo el clásico “compila pero no arranca” o “arranca pero los tests no encuentran el módulo”. El campo imports colapsa las tres en un único mecanismo que el propio runtime entiende: funciona en node app.js sin compilar nada, y TypeScript lo resuelve de forma nativa con moduleResolution: "nodenext" o "bundler". En 2026 la recomendación es directa: usa #imports para los alias internos y reserva paths únicamente para lo que imports no cubra. Un alias, una fuente de verdad, cero drift entre el type-checker, el bundler y el runtime.
La diferencia de ámbito, resumida:
| Mecanismo | Ámbito | Vive en runtime |
|---|---|---|
#imports |
el propio paquete | sí, nativo |
tsconfig paths |
el type-checker | no, solo tipos |
resolve.alias |
el bundler | no, solo build |
El soporte en 2026 ya es amplio: Node lo implementa de forma nativa, TypeScript lo resuelve con moduleResolution: "nodenext" o "bundler", y los bundlers y runners modernos —Vite, webpack, Deno, Vitest— lo respetan sin plugins. Eso convierte a #imports en la opción con menos piezas móviles: un único campo del package.json que entienden a la vez el runtime, el type-checker, el bundler y los tests, sin ninguna copia que sincronizar.
Antes de que imports existiera, la única forma de tener alias internos portables era la magia de cada herramienta o enlaces simbólicos manuales en node_modules, frágiles y no versionables. Que hoy el propio package.json lo resuelva, entendido por Node, TypeScript y los bundlers a la vez, borra una categoría entera de configuración. Si arrancas un proyecto ahora, empieza por #imports para los alias internos y añade otro mecanismo solo cuando descubras algo que este no cubra.
- Reemplaza tres
../../de un módulo por un alias#y comprueba que Node lo resuelve connode, sin compilar. - Define
#cryptocon ramasnodeybrowser, e imprime desde cada entorno para ver la variante elegida. - Intenta importar
#configdesde otro paquete y confirma que falla: es privado por diseño. - Configura TypeScript con
moduleResolution: "nodenext"y verifica que resuelve#sin necesidad depaths.