Edit files in place
Every project ends up doing the same dance: read a file, change one thing,
write it back. Bump a version. Flip a flag. Push a log line. Done by hand
that’s three steps and a variable you have to name. The facade turns each
of those into one call — you pass a function, fs handles the read and
the write.
import { fs } from "@warlock.js/fs";
await fs.files.editJson("package.json", (pkg) => ({ ...pkg, version: "4.7.0" }));That’s the whole read-modify-write loop. No getJson, no temp variable, no
putJson. This guide is the tour of that family.
edit(path, text => text) — patch a text file
Section titled “edit(path, text => text) — patch a text file”edit reads the file as a string, hands it to your function, and writes
whatever you return.
await fs.files.edit("README.md", (md) => md.replaceAll("4.6", "4.7"));Compare that to doing it by hand — the thing you’d otherwise write:
const md = await fs.files.get("README.md");await fs.files.put("README.md", md.replaceAll("4.6", "4.7"));Same result, but edit names the pattern for you: the argument is the
current contents, the return value is the new contents. Nothing to
mistype, nothing to forget to write back.
editJson(path, obj => obj) — transform a JSON document
Section titled “editJson(path, obj => obj) — transform a JSON document”editJson is edit for JSON: it parses, calls your function with the
parsed object, and writes the (pretty-printed) result. This is the killer
one — bumping a version, retargeting a field, rewriting a nested value.
await fs.files.editJson<Pkg>("package.json", (pkg) => ({ ...pkg, version: bumpPatch(pkg.version),}));Because you get the whole document, you can do anything — including push into an array, which nothing else in the family does:
await fs.files.editJson<Manifest>("manifest.json", (m) => ({ ...m, files: [...m.files, "styles.css"],}));mergeJson(path, partial, { deep? }) — patch a config object
Section titled “mergeJson(path, partial, { deep? }) — patch a config object”When you only want to set a few fields and leave the rest alone, you don’t
need a whole transform function — hand mergeJson a partial object and it
merges it in. Shallow by default; pass { deep: true } to recurse into
nested objects.
await fs.files.mergeJson("config.json", { flags: { queues: true } });The deep flag is the whole story. Say config.json starts as:
{ "flags": { "cache": true }, "port": 3000 }A shallow merge replaces flags wholesale — the old keys inside it are gone:
await fs.files.mergeJson("config.json", { flags: { queues: true } });{ "flags": { "queues": true }, "port": 3000 }A deep merge recurses into flags and keeps cache:
await fs.files.mergeJson("config.json", { flags: { queues: true } }, { deep: true });{ "flags": { "cache": true, "queues": true }, "port": 3000 }ensureJson(path, fallback) — get-or-create config
Section titled “ensureJson(path, fallback) — get-or-create config”Loading a config file that might not exist yet? ensureJson returns the
parsed document if it’s there, or writes fallback and returns that.
const config = await fs.files.ensureJson("config.json", { port: 3000, host: "localhost",});No exists check, no try/catch around a missing-file read. First run
writes the defaults; every run after reads them. Pair it with the family
above — ensureJson to get the doc into existence, mergeJson / editJson
to evolve it from there.
appendJsonLine(path, value) — NDJSON logs
Section titled “appendJsonLine(path, value) — NDJSON logs”Not every JSON file is one document. A log is a stream of records, one
per line — NDJSON. appendJsonLine
serializes one value and appends it as a single line.
await fs.files.appendJsonLine("events.ndjson", { type: "signup", at: Date.now() });Each call adds exactly one line:
{"type":"signup","at":1719800000000}{"type":"login","at":1719800600000}This is not the same as pushing into an array with editJson. The array
approach re-reads and re-writes the entire file on every append — O(n) and
increasingly slow as the log grows. appendJsonLine just appends bytes.
The JSON family — pick the right one
Section titled “The JSON family — pick the right one”| Method | When |
|---|---|
getJson(path, { schema?, default? }) | read a document (optionally validate + fall back when missing) |
putJson(path, value, { indent? }) | overwrite the whole document |
mergeJson(path, partial, { deep? }) | set a few known fields; leave the rest (shallow, or deep) |
editJson(path, fn) | transform based on the old value — bump, compute, push into an array |
ensureJson(path, fallback) | read it, or create it if missing |
appendJsonLine(path, value) | one record per line (a log) — not one document |
Rough rule: reading → getJson; replacing → putJson; patching known
fields → mergeJson; computing from the current value → editJson;
first-run defaults → ensureJson; a growing log → appendJsonLine.
Handles, not paths
Section titled “Handles, not paths”Every method here also lives on a fs.file() handle, so
if you’re already holding one you skip repeating the path:
const pkg = fs.file("package.json");await pkg.editJson<Pkg>((p) => ({ ...p, version: "4.7.0" }));await pkg.mergeJson({ private: true });Related
Section titled “Related”- The fs facade — the full
fs.*surface these live on. - Read and write files — plain
get/putwhen there’s nothing to transform. - Write atomically — for files other processes read mid-write.
- Reference / API — full signatures.