Module resolution
The TypeScript-aware resolution Nub layers on top of Node — tsconfig path aliases and base URL, extends chains, extensionless imports, and the JavaScript-to-TypeScript extension swap.
Nub adds a TypeScript-aware resolution layer on top of Node's own resolver.
// tsconfig.json paths alias — works with no extra tooling
import { db } from "@db";
// extensionless — resolves ./config.ts
import { config } from "./config";
// .js→.ts swap — resolves ./handler.ts when no .js exists on disk
import { handler } from "./handler.js";If your editor's Go-to-Definition works on an import and tsc accepts it, Nub resolves it the same way at runtime — no tsconfig-paths package, no build step. Nub handles only the cases Node does not define: tsconfig.json path aliases, extensionless imports in TypeScript files, the .js→.ts emit-convention swap, and subpaths into dependencies that declare no exports. Everything else — package names, exports / imports maps, export conditions — falls through to Node unchanged.
tsconfig.json
Nub reads your nearest tsconfig.json and applies its resolution settings at runtime — paths, baseUrl, extends — matching what your editor and tsc already do.
paths
The compilerOptions.paths and compilerOptions.baseUrl mappings resolve @/...-style aliases.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@db": ["src/db/index.ts"]
}
}
}import { db } from "@db";
import { Button } from "@/components/Button";Matching follows the tsc rules: an exact pattern wins outright, and among wildcard patterns the longest matching prefix wins. Node builtins always take precedence — import "os" is node:os, never baseUrl/os.ts.
Read the full docs for paths on typescriptlang.org.
Path aliases work from any file, not just TypeScript ones — a .js file that imports a paths alias resolves it too. (The relative extensionless and .js→.ts behaviors below are the ones scoped to TypeScript-family parents.)
baseUrl
With baseUrl set and no matching paths entry, a bare specifier resolves relative to the base directory.
{ "compilerOptions": { "baseUrl": "." } }import { config } from "lib/config"; // resolves <baseUrl>/lib/config.tsA bare specifier with no baseUrl-relative file on disk falls through to Node's node_modules resolution.
extends
Nub walks up to the nearest tsconfig.json and follows extends chains, including array extends (later entries win) and package extends like extends: "@tsconfig/node20/tsconfig.json" resolved through node_modules. The TS 5.5 ${configDir} template is interpolated against the consuming config's directory.
Configs are read once per process. Edit your tsconfig.json and restart the process, or run under nub watch, which restarts it.
Extensionless imports
In TypeScript-family files, extensionless imports resolve the way tsc resolves them — import "./foo" finds ./foo.ts.
// from a .ts / .tsx / .mts / .cts file:
import { config } from "./config"; // resolves ./config.ts
// resolves ./fixtures/index.ts (or its package.json "main")
import data from "./fixtures";The probe order is extension-aware — it depends on the parent file's extension:
.ts parent → .ts .tsx .js .jsx .json
.tsx parent → .tsx .ts .jsx .js .json # JSX extensions sort first
.mts parent → .mts .ts .mjs .js .json
.cts parent → .cts .ts .cjs .js .jsonA directory import honors the directory's package.json "main" before falling back to probing index.<ext> in that same parent-aware order. Only "main" is consulted — exports is never read for a directory-path import, matching Node, which honors exports only for package-name resolution.
This probing is scoped to TypeScript-family parent files (.ts / .tsx / .mts / .cts) with a relative specifier. A plain .js file requires relative extensions, as under Node. Subpaths into a dependency are covered below and follow a different rule.
Package subpaths
A subpath into a dependency gets the same probing, so an extensionless import finds the file on disk and a workspace package whose main points at TypeScript source resolves by subpath too.
// finds node_modules/prismjs/components/prism-python.js
import "prismjs/components/prism-python";
// a workspace package with "main": "./index.ts" and no exports map
// resolves packages/lib/prompt.ts
import { createPrompt } from "@repo/lib/prompt";The order here is fixed, and unlike relative imports it does not vary with the importing file's extension:
.js .json .ts .tsxJavaScript sorts first because a published package's .ts is unshipped source that Nub does not transpile inside node_modules. A workspace package symlinked into node_modules ships no .js, so the probe reaches its TypeScript.
Subpath probing applies from any importing file, .js included.
A subpath naming a directory resolves that directory's index file, the way a require of the same subpath already does:
import { parse } from "qs/lib"; // resolves qs/lib/index.jsNub's probe consults only index; a directory's own package.json main is left to Node's resolver.
exports is never probed; its map alone decides what is reachable, as on Node..js → .ts resolution
With moduleResolution: "nodenext", tsc requires a .js extension even though the file on disk is .ts, so Nub maps the written .js back to the .ts on disk.
// resolves ./handler.ts when no ./handler.js exists
import { handler } from "./handler.js";
// and across a package subpath, into a workspace package built from TS
import { createPrompt } from "@repo/lib/prompt.js";The swap only fires when the written extension points at a file that doesn't exist. The same rewrite covers .jsx → .tsx, .mjs → .mts, and .cjs → .cts, and likewise a real .cjs wins over a sibling .cts.
Node's resolver
Node's own resolver handles everything else, unchanged:
- package names
exports/importsmaps- export conditions
- every subpath into a dependency that declares
exports
Read the full docs for package entry points and conditions on nodejs.org.
Adding export conditions
Add your own in a nub.jsonc and every run in the project resolves against them.
{
// ...
"conditions": ["development"] // matched ahead of "default"
}{
"exports": {
".": {
"development": "./development.js",
"default": "./production.js"
}
}
}Every resolution in the project matches against them, a bare-specifier preload included.
The Nub condition
The Nub CLI matches a nub condition on every run it augments, so a package that publishes a branch for it resolves there. The name follows the same runtime keys convention bun, deno and workerd use.
{
"exports": {
".": {
"nub": "./dist/nub.js",
"default": "./dist/index.js"
}
}
}Point the branch at JavaScript. Files inside node_modules are never transpiled, so a TypeScript target fails to load under Nub.
Two runs do not match it. Passing --node runs with Node's own conditions and nothing else, and the standalone runner leaves Node's condition set alone so a file resolves the same way under it as under tsx. Both fall through to default.
Yarn Plug'n'Play
Nub enables Plug'n'Play automatically. Walking up from the working directory, it detects a .pnp.cjs at the project root and injects it ahead of its own preload (--require .pnp.cjs on the fast tier, the NODE_OPTIONS equivalent on the compat tier), so PnP's resolver patches install first and Nub's TypeScript resolution layers on top.
nub index.ts # .pnp.cjs is detected and loaded automatically
nub run build # same for run scripts and nubxBoth require and import resolve under PnP, by different paths:
| Module system | Resolved by |
|---|---|
CommonJS (require) | PnP's own _resolveFilename patch, installed by the injected --require .pnp.cjs |
ESM (import) | Nub's resolve hook calling pnpapi.resolveRequest — honoring import conditions (a dual package picks its import build) and tagging the format (zip-stored pure-ESM loads as ESM) |
Nub doesn't register Yarn's .pnp.loader.mjs (its ESM resolver collides with the fast tier's module.registerHooks), so the same pnpapi.resolveRequest path serves both tiers.
Zip-stored packages resolve too — import from a .yarn/cache zip, and nubx running a zip-stored binary. This is the runtime running a PnP project; Nub does not produce a PnP install. Package extends chains resolve through the node_modules walk rather than the PnP API, which covers the npm and pnpm installs where that form appears.
Read the full docs for Plug'n'Play on yarnpkg.com.
Decorators
How Nub runs legacy TypeScript decorators and their emitted design-type metadata on stock Node — the form the NestJS, TypeORM, and Angular dependency-injection ecosystem is written against.
Environment files
Automatic environment-file loading — the file set, mode selection from APP_ENV or a clamped NODE_ENV fallback, precedence, variable expansion, the skip under the test environment, loading explicit files with --env-file, and disabling all loading with --no-env-file.