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:
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"]));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):
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;
});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
execFileAsyncrejects with an error that also hasstdout,stderrandcode; type thecatchvariable before reading them, since it isunknownunderstrict. - Use
??for the exit code.code ?? 1turns a signal exit into a failure, as in the first example. spawnwithshell: truechanges 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
- Child process (execFile and spawn) · Node.js documentation
- util.promisify · Node.js documentation
Last reviewed 21 October 2026.