Recipe · Node and platforms

How do I type Node.js fs callbacks and fs/promises in JavaScript?

  • Node.js (ES modules)
  • node:fs
  • @types/node
  • Callbacks
  • Promises
  • Checked with TypeScript 5.9

The problem I read files with node:fs, sometimes with callbacks and sometimes with fs/promises. What do I need for tsc to type the error and the data, and why is my file content a Buffer?

The short answer

Install @types/node, and pass an encoding when you want text. tsc types the callback’s error and text, and the promise’s result, from the call itself:

inventory.js inventory.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
import { readFile } from "node:fs";
import { readFile as readFileAsync } from "node:fs/promises";

/**
 * @param {string} path
 * @param {(error: NodeJS.ErrnoException | null, skus?: string[]) => void} done
 */
export function readSkus(path, done) {
  readFile(path, "utf8", (error, text) => {
    if (error) {
      done(error.code === "ENOENT" ? null : error, []);
      return;
    }
    done(null, text.split("\n").filter(Boolean));
  });
}

/**
 * @param {string} path
 * @returns {Promise<string[]>}
 */
export async function readSkusAsync(path) {
  const text = await readFileAsync(path, "utf8");
  return text.split("\n").filter(Boolean);
}

readSkus("skus.txt", (error, skus) => console.log(error?.message, skus?.length));
console.log(await readSkusAsync("skus.txt"));
import { readFile } from "node:fs";
import { readFile as readFileAsync } from "node:fs/promises";

export function readSkus(path: string, done: (error: NodeJS.ErrnoException | null, skus?: string[]) => void): void {
  readFile(path, "utf8", (error, text) => {
    if (error) {
      done(error.code === "ENOENT" ? null : error, []);
      return;
    }
    done(null, text.split("\n").filter(Boolean));
  });
}

export async function readSkusAsync(path: string): Promise<string[]> {
  const text = await readFileAsync(path, "utf8");
  return text.split("\n").filter(Boolean);
}

readSkus("skus.txt", (error, skus) => console.log(error?.message, skus?.length));
console.log(await readSkusAsync("skus.txt"));
The callback API and the promise API of node:fs.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

With "utf8", text is a string. The callback receives error as NodeJS.ErrnoException | null, so the code checks it before using text.

Why it works

@types/node declares readFile with overloads: one for an encoding, which passes a string, and one without, which passes a Buffer. tsc picks the overload from your arguments and types the callback’s parameters from it, so the arrow function needs no JSDoc. The promise API has the same overloads, returning Promise<string> or a promise of a buffer.

The same thing in TypeScript

The TypeScript equivalent tab writes the callback type inline in the parameter list. The calls into node:fs are identical; the types come from @types/node in both cases.

What goes wrong

The error parameter is null when the read succeeds, so reading error.code without a check is TS18047. Without an encoding the result is a Buffer, which has no split (TS2339); @types/node 24 calls that buffer type NonSharedBuffer:

inventory.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: inventory.js(6,15): error TS18047: 'error' is possibly 'null'. inventory.js(11,18): error TS2339: Property 'split' does not exist on type 'NonSharedBuffer'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. inventory.js(6,15): error TS18047: 'error' is possibly 'null'. inventory.js(11,18): error TS2339: Property 'split' does not exist on type 'NonSharedBuffer'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. inventory.js(6,15): error TS18047: 'error' is possibly 'null'. inventory.js(11,18): error TS2339: Property 'split' does not exist on type 'NonSharedBuffer'. checked 21 October 2026
import { readFile } from "node:fs";
import { readFile as readFileAsync } from "node:fs/promises";

readFile("skus.txt", "utf8", (error, text) => {
  console.log(error.code, text.length);error TS18047: 'error' is possibly 'null'.
});

const data = await readFileAsync("skus.txt");
console.log(data.split("\n"));error TS2339: Property 'split' does not exist on type 'NonSharedBuffer'.
The error before checking it, and a read without an encoding.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

Gotchas

  • types lists need "node". If jsconfig.json has a types array, add "node" to it, or @types/node is not loaded.
  • Match @types/node to your Node.js version. Install the major version that matches the runtime (@types/node@24 for Node.js 24) so new APIs are declared and removed ones are not.
  • fs.exists style checks race. Read the file and handle ENOENT instead of checking first; the types make the error code easy to test.

Questions

What is NodeJS.ErrnoException?

The error type @types/node uses for failed system calls: an Error with optional code, errno, path and syscall. Checking error.code === "ENOENT" is how code tells a missing file from other failures.

Should I use callbacks or promises?

For new code, node:fs/promises with await: errors become exceptions, and the types are the same. Callbacks remain for streaming patterns and older code.

Sources

Last reviewed 21 October 2026.