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 owns exactly the cases Node has no opinion on: 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 with no extra tooling.
{
"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.
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. A import "lib/config" finds baseUrl/lib/config.ts on its own.
{ "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 let nub watch restart it for you.
Extensionless imports
In TypeScript-family files, extensionless imports resolve the way tsc resolves them — import "./foo" finds ./foo.ts. This is the conventional pattern most TypeScript codebases use.
// 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 stays strict about relative extensions, exactly like vanilla 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
import { createPrompt } from "@repo/lib/prompt"; // resolves packages/lib/prompt.tsThe 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 at all, so the same order still lands on its TypeScript.
Because the rule is about the dependency's shape rather than the importer's, it is not scoped to TypeScript-family parents — a .js file in a checkJs project resolves these the same way.
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.jsOnly index is consulted. A directory's own package.json main is not read for a subpath — Node's require reads it, and deferring keeps both resolvers on the same answer. A trailing slash means what it says: qs/lib/ is the directory, never a qs/lib.js beside it.
exports is never probed. Its map decides what is reachable, exactly as on Node, so a subpath it withholds stays an error..js → .ts resolution
With moduleResolution: "nodenext", tsc wants a .js extension even though the file on disk is .ts. Nub reverses that at runtime, so the same source runs before and after a build.
// 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 source
import { createPrompt } from "@repo/lib/prompt.js";The literal extension always wins first: a real on-disk ./handler.js is used as-is, and 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 beats a sibling .cts. This is the tsc/tsx emit convention, not a guess at the file's type.
What falls through to Node
Nub resolves only the TypeScript-aware cases Node has no opinion on. Everything else is Node's job, handled by Node's own resolver unchanged:
- package names
exports/importsmaps- export conditions
- every subpath into a dependency that declares
exports
Yarn Plug'n'Play
A project Yarn installed in PnP mode just runs — Nub enables Plug'n'Play automatically, with no flag and no setup. Walking up from the working directory, Nub 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.
It works across the whole supported Node range (18.19+), on macOS, Linux, and Windows — including import of zip-stored packages and running a zip-stored binary through nubx. This is the runtime running a PnP project; Nub's installer does not itself produce a PnP install — that stays Yarn's job. Package extends chains resolve through the node_modules walk rather than the PnP API — which still covers the common npm / pnpm install.
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.