Read and write files
Every Node project ends up doing the same handful of things: read a config
file, write a build artifact, check whether a state file exists. This is how
you do all of that with @warlock.js/fs.
The facade is the front door. File operations live under fs.files.*, and
each one is a single await — no imports to remember, no manual parent-dir
creation, no casts.
import { fs } from "@warlock.js/fs";
const config = await fs.files.get("config.toml");await fs.files.put("out/log.txt", "hello world\n");Reading text
Section titled “Reading text”fs.files.get reads a file as a UTF-8 string — the common case in one call.
const readme = await fs.files.get("README.md"); // stringNeed the raw bytes instead? Pass { encoding: null } and you get a Buffer
back. The return type follows the option, so there’s no cast either way.
const bytes = await fs.files.get("logo.png", { encoding: null }); // BufferIt throws ENOENT if the file is missing. Don’t try/catch that — gate the
read with an existence check instead.
Reading JSON
Section titled “Reading JSON”fs.files.getJson<T> reads, parses, and hands you a typed value.
type Manifest = { version: string; files: string[] };
const manifest = await fs.files.getJson<Manifest>("manifest.json");// ^? ManifestBy default a missing file throws. Pass { default } to get a fallback back
instead — perfect for optional config that may not exist yet.
const settings = await fs.files.getJson("settings.json", { default: {} });// never throws on ENOENT — you get {} insteadValidate while you read
Section titled “Validate while you read”The generic above is only a type assertion — JSON.parse returns whatever is
actually in the file. When you can’t trust the source, pass a schema and the
parsed value is validated before it reaches you.
import { v } from "@warlock.js/seal";import { fs } from "@warlock.js/fs";
const config = await fs.files.getJson("config.json", { schema: v.object({ port: v.number(), host: v.string() }), default: { port: 3000, host: "localhost" },});The schema accepts any Standard Schema
validator — @warlock.js/seal, Zod, or Valibot all work, because
@warlock.js/fs calls the schema’s own ~standard.validate hook. That means
the package stays zero-dependency: nothing is bundled, and you bring your
validator of choice.
Writing text
Section titled “Writing text”fs.files.put writes a UTF-8 string and creates any missing parent
directories along the way. No ensureDir dance first.
await fs.files.put("out/nested/log.txt", "hello world\n");// out/ and out/nested/ are created automaticallyFor files another process might read mid-write — a config a dev server
watches, a manifest a deploy script consumes — pass { atomic: true }. The
write goes to a temp file and renames into place, so readers never see a
half-written file.
await fs.files.put("cache/data.json", "{}", { atomic: true });By default put overwrites. Pass { overwrite: false } to make it refuse to
clobber an existing file (it throws instead).
await fs.files.put("seed.txt", "initial", { overwrite: false });Writing JSON
Section titled “Writing JSON”fs.files.putJson serializes and writes in one step, with the same
auto-parents behavior.
await fs.files.putJson("out/manifest.json", { version: "1.0.0", files: ["bundle.js", "styles.css"],});Output is pretty-printed at 2-space indent by default. Control it with
{ indent } — pass 0 for minified output.
await fs.files.putJson("out/min.json", value, { indent: 0 });Create-if-absent
Section titled “Create-if-absent”When you want to write a file only if it isn’t there yet — a scaffolded
default, a seed record — use create / createJson. They’re exactly put /
putJson with { overwrite: false } baked in.
await fs.files.create("config.toml", defaultConfig); // textawait fs.files.createJson("state.json", { counter: 0 }); // JSONBoth throw if the file already exists, so a re-run won’t quietly wipe live data.
Existence
Section titled “Existence”fs.files.exists answers “is there a regular file here?”.
if (!(await fs.files.exists("config.toml"))) { await fs.files.put("config.toml", defaultConfig);}Gating a write like this reads far better than catching ENOENT as control
flow. If you don’t care whether the path is a file or a directory, use the
type-agnostic fs.exists.
await fs.exists("build-output"); // true for a file OR a directoryFor directory-specific checks and the rest of the folder toolkit, see Manage directories.
Metadata
Section titled “Metadata”fs.files.stats returns a normalized FileStats object — the fields you
actually reach for, without the raw fs.Stats noise.
const info = await fs.files.stats("bundle.js");// { path, name, size, type, lastModified, raw }size is bytes, lastModified is a Date, type distinguishes file from
directory, and raw is the underlying fs.Stats if you need mode bits. When
size is all you want, fs.files.size skips straight to the number.
const bytes = await fs.files.size("bundle.js"); // numberBoth throw ENOENT on a missing path — guard with fs.files.exists if the
file might not be there.
Related
Section titled “Related”- The fs facade — the full
fs.*surface at a glance. - Write atomically — for files concurrent readers see.
- Manage directories — list, copy, move, delete.
- Hash files — fingerprint for cache invalidation.
- Reference / API — full signatures, including the sync primitives.