10LOC
Node.jsintermediate

Use fs.cp for simple directory copies

Published July 9, 2027

import { cp } from "node:fs/promises";

export const copyDirectory = (src: string, dest: string, options: { overwrite?: boolean } = {}) =>
  cp(src, dest, {
    recursive: true,
    force: options.overwrite ?? false,
    errorOnExist: !options.overwrite,
    filter: (source, destination) =>
      !source.includes("node_modules") && !destination.includes(".cache"),
  });

What

copyDirectory(src, dest) recursively copies a directory with node:fs/promises' cp, skipping node_modules and .cache via a filter, with an explicit overwrite switch controlling what happens if dest already exists.

Why it matters

Before fs.cp, recursively copying a directory in Node meant either shelling out to cp -r (not cross-platform — there's no cp on a bare Windows install — and gives you no filtering or structured error handling), or hand-rolling a walk with fs.readdir + fs.mkdir + fs.copyFile recursion. fs.cp does that walk internally and exposes a filter hook and explicit conflict handling, without a subprocess or a dependency like fs-extra.

How it works

  • recursive: true is required for directory sources — cp is built to copy single files too, so recursion isn't the default and it throws on a directory without it.
  • filter receives both the source and destination path for every entry being copied and returns a boolean (or a Promise<boolean>). Returning false for a directory skips that directory and everything inside it, not just the one entry.
  • force and errorOnExist interact rather than acting as independent switches. force defaults to true — an existing destination is silently overwritten unless you turn it off. errorOnExist only has an effect when force is false; it's ignored entirely when force is true. Passing overwrite: true here sets force: true (so errorOnExist doesn't matter), and the default (overwrite: false) sets force: false, errorOnExist: true — an existing destination throws instead of getting silently touched.

Gotchas

  • fs.cp's own default (if you don't touch the options at all) is to overwrite — cp(src, dest, { recursive: true }) alone silently clobbers an existing dest. Being explicit about force/errorOnExist, like this wrapper is, avoids relying on that default.
  • Copying is not atomic. A crash partway through a large tree leaves a partially-copied destination with no automatic rollback.
  • fs.cp only became stable (non-experimental) in Node 22.3.0, after shipping experimental back in 16.7.0 — worth checking your Node version if a specific option doesn't seem to behave as documented.

Related

  • fs.copyFile — the single-file primitive this builds on; reach for it directly when you know you're copying exactly one file and don't need recursion or filtering.
  • child_process.exec("cp -r ...") — the pre-fs.cp escape hatch. Still works, but isn't cross-platform and gives you no filter hook or structured error codes.