Skip to content

file() Utility

When to use

Use file() when reading static file contents in Node.js — SQL queries, templates, config files, and similar assets. It replaces fs.readFileSync with automatic caller-relative path resolution: write file("./queries/list.sql") from any module and get the right file regardless of how the process was started.

The pattern

The file() function wraps fs.readFileSync with automatic caller-relative path resolution so you can write file("./queries/list.sql") from any module and get the right file regardless of how the process was started.

SQL-as-files pattern

Load .sql files into a query map at the top of a model module. Each query lives in its own file for syntax highlighting, linting, and version control:

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

const AccountQuery = {
  list: file("./queries/account-list.sql"),
  create: file("./queries/account-create.sql"),
  get: file("./queries/account-get.sql"),
  update: file("./queries/account-update.sql"),
  delete: file("./queries/account-delete.sql"),
};

Then use the query strings with your database driver:

javascript
const accounts = await query(AccountQuery.list);
const account = await query(AccountQuery.get, { id: 42 });

Loading templates and other files

The file() function works with any text file format. Load an HTML template at module initialization time so it is available immediately for rendering:

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

const template = file("./templates/welcome.html");

Pass an options object to override the default UTF-8 encoding when reading binary content:

javascript
const binary = file("./assets/logo.png", {
  encoding: "base64",
});

Bare specifiers resolve through Node's module resolution algorithm, letting you read files from installed packages:

javascript
const readme = file("some-package/README.md");

Project structure

Keep query files alongside the model that uses them. Each model directory has its own queries/ folder so paths stay short and co-location makes ownership obvious:

models/
├── account/
│   ├── index.js          # imports file(), builds query map
│   └── queries/
│       ├── account-list.sql
│       ├── account-create.sql
│       ├── account-get.sql
│       ├── account-update.sql
│       └── account-delete.sql
└── transaction/
    ├── index.js
    └── queries/
        └── ...

Implementing file()

The function resolves the given path relative to the calling module, then reads the file synchronously with a default UTF-8 encoding that callers can override:

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

export function file(filePath, options = {}) {
  const absolutePath = resolve(filePath, caller());
  return fs.readFileSync(absolutePath, {
    encoding: "utf-8",
    ...options,
  });
}

The caller package inspects the call stack to find the file path of the module that called file(). This makes relative paths resolve from the caller's directory, not from process.cwd().

Trade-offs

  • Synchronous I/O at import time. This blocks the event loop during startup. That's acceptable for a small number of static files but inappropriate for large files or dynamic loading.
  • Depends on caller package. The caller package reads the V8 stack trace to find the calling file. This works reliably but is a runtime dependency with an implicit contract.
  • Files are read once and cached in variables. Changes to files on disk after module initialization are not picked up. This is a feature for production but can surprise during development.
  • Not suitable for user-provided paths. The path goes directly to fs.readFileSync — never pass untrusted input.