Skip to content

File Path Resolver

When to use

Use this when you need a single resolve() function that handles three kinds of paths — absolute, relative, and module — and resolves them from the caller's location rather than process.cwd().

The pattern

The resolve() function handles three kinds of paths — absolute, relative, and module — and resolves them from the caller's location rather than process.cwd().

Resolving different path types

A relative path resolves from the calling module's directory. If this file lives at /app/src/models/account.js, the result is an absolute path under that same directory:

javascript
import { resolve } from "@chriscalo/file";

// If this file is /app/src/models/account.js,
// resolves to /app/src/models/queries/list.sql
const sqlPath = resolve("./queries/list.sql");

A bare specifier delegates to Node's module resolution algorithm, finding files inside installed packages:

javascript
const configPath = resolve("my-package/config.json");

When building higher-level abstractions like template include() functions, pass the caller path explicitly so resolution stays relative to the parent template rather than the include() wrapper:

javascript
const parentFile = "/app/views/layout.ejs";
const childPath = resolve("./header.ejs", parentFile);
// => /app/views/header.ejs

Using in template systems

The three-way resolution is especially useful for template includes where paths can be relative to the parent template, absolute, or module references:

javascript
function createInclude(parentPath, data) {
  return function include(includePath, includeData) {
    const templatePath = resolve(
      includePath, parentPath
    );
    return render(templatePath, {
      ...data,
      ...includeData,
    });
  };
}

Each nested include() resolves paths relative to its own parent template, creating a natural directory-relative model.

Implementing resolve()

The function inspects the path to determine its type, then resolves accordingly. Absolute paths (starting with /) pass through unchanged, relative paths (starting with ./ or ../) resolve from the caller's directory, and bare specifiers delegate to require.resolve().

javascript
import path from "node:path";
import caller from "caller";

export function resolve(pathString, callerPath) {
  if (callerPath === undefined) {
    callerPath = caller();
  }
  
  const { dir } = path.parse(pathString);
  const isAbsolute = path.isAbsolute(pathString);
  const isRelative =
    !isAbsolute && String(dir).startsWith(".");
  const isModule = !isAbsolute && !isRelative;
  
  if (isAbsolute) {
    return pathString;
  } else if (isRelative) {
    const callerDir = path.dirname(callerPath);
    return path.resolve(callerDir, pathString);
  } else if (isModule) {
    return require.resolve(pathString);
  } else {
    throw new Error(
      `Can't resolve path: ${pathString}`
    );
  }
}

The callerPath parameter lets you pass the caller explicitly when building higher-level abstractions (like an include() function for templates). When omitted, the caller package reads it from the stack trace.

Trade-offs

  • require.resolve() for module paths. This works in CommonJS but needs adaptation for pure ESM projects. In ESM, use import.meta.resolve() instead.
  • caller package dependency. When callerPath is not passed, the resolver inspects the stack trace. This is convenient but adds a runtime dependency and can behave unexpectedly if called through wrapper functions that add extra stack frames.
  • No URL support. This pattern works with file system paths only. For file:// URLs (common in ESM with import.meta.url), convert with fileURLToPath() first.
  • Security. The resolver does not sandbox paths. Absolute paths and ../ traversals can reach anywhere on the file system. Never use with untrusted input.