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.ts

A 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  .json

A 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.ts

The order here is fixed, and unlike relative imports it does not vary with the importing file's extension:

.js  .json  .ts  .tsx

JavaScript 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.js

Only 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.

A dependency that declares 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 / imports maps
  • 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 nubx

Both require and import resolve under PnP, by different paths:

Module systemResolved 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.