Recipe · Node and platforms

How do I type child_process results in Node.js JavaScript?

  • Node.js (ES modules)
  • child_process
  • promisify
  • @types/node
  • Checked with TypeScript 5.9

The problem My build script runs git and npm with child_process. What types do execFile and spawn give me in JSDoc-checked code, and why does tsc say stdout might be null?

The short answer

Wrap execFile with util.promisify for short commands, and use spawn for long-running ones whose output you stream. @types/node types both from the call:

git.js git.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 { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

/** @returns {Promise<string>} */
export async function currentCommit() {
  const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"]);
  return stdout.trim();
}

/**
 * @param {string[]} args
 * @returns {Promise<number>}
 */
export function runTests(args) {
  const child = spawn("npm", ["test", "--", ...args]);
  child.stdout.on("data", (chunk) => process.stdout.write(chunk));
  return new Promise((resolve) => {
    child.on("close", (code) => resolve(code ?? 1));
  });
}

console.log(await currentCommit(), await runTests(["--run"]));
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

export async function currentCommit(): Promise<string> {
  const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"]);
  return stdout.trim();
}

export function runTests(args: string[]): Promise<number> {
  const child = spawn("npm", ["test", "--", ...args]);
  child.stdout.on("data", (chunk) => process.stdout.write(chunk));
  return new Promise((resolve) => {
    child.on("close", (code) => resolve(code ?? 1));
  });
}

console.log(await currentCommit(), await runTests(["--run"]));
execFile through promisify, and spawn with its output streams.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

The awaited execFileAsync result has stdout and stderr as strings, and with the default stdio setting child.stdout is a readable stream that is never null.

Why it works

execFile has a custom promisified form in Node.js, and @types/node declares it, so promisify(execFile) returns a function whose promise resolves to { stdout, stderr }. spawn returns a ChildProcess; when you pass no stdio option, @types/node picks an overload whose stdin, stdout and stderr can never be null, because all three are pipes.

The same thing in TypeScript

The TypeScript equivalent tab is the same code with the parameter and return types inline. Everything about the child process itself comes from @types/node in both files.

What goes wrong

Once stdio is passed, the streams can be null: here stderr is "inherit", so there is no pipe to read (TS18047). And the close event’s exit code is null when the process was killed by a signal, so it does not fit a number (TS2322):

git.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: git.js(5,1): error TS18047: 'child.stderr' is possibly 'null'. git.js(10,9): error TS2322: Type 'number | null' is not assignable to type 'number'. Type 'null' is not assignable to type 'number'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. git.js(5,1): error TS18047: 'child.stderr' is possibly 'null'. git.js(10,9): error TS2322: Type 'number | null' is not assignable to type 'number'. Type 'null' is not assignable to type 'number'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. git.js(5,1): error TS18047: 'child.stderr' is possibly 'null'. git.js(10,9): error TS2322: Type 'number | null' is not assignable to type 'number'. Type 'null' is not assignable to type 'number'. checked 21 October 2026
import { spawn } from "node:child_process";

const child = spawn("npm", ["test"], { stdio: ["ignore", "pipe", "inherit"] });
child.stderr.pipe(process.stderr);error TS18047: 'child.stderr' is possibly 'null'.

child.on("close", (code) => {
  /** @type {number} */
  const exitCode = code;error TS2322: Type 'number | null' is not assignable to type 'number'.
  Type 'null' is not assignable to type 'number'.
  process.exitCode = exitCode;
});
Output streams that may be null, and an exit code that may be null.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

Gotchas

  • Rejections carry the output. When the command exits with a non-zero code, the promise from execFileAsync rejects with an error that also has stdout, stderr and code; type the catch variable before reading them, since it is unknown under strict.
  • Use ?? for the exit code. code ?? 1 turns a signal exit into a failure, as in the first example.
  • spawn with shell: true changes quoting, not types. The result is typed the same way; the difference is how the command line is interpreted.

Questions

Why execFile and not exec?

execFile runs a program with an argument array and no shell, so arguments are passed as they are. exec runs a command line through a shell, which needs quoting; both are typed the same way by @types/node.

How do I get stdout as a Buffer?

Pass { encoding: "buffer" }: @types/node's overloads then type stdout and stderr as buffers instead of strings.

Sources

Last reviewed 21 October 2026.