stat() answers everything about a file except its contents: size, type, timestamps and permission bits. lstat() is identical except that it describes a symbolic link itself instead of following it — what you want when walking a tree you must not step outside of.
import { stat, access, chmod, constants } from 'node:fs/promises';
import { statSync } from 'node:fs';
const s = await stat('package.json');
console.log('size :', s.size, 'bytes, dir?', s.isDirectory());
console.log('mode :', (s.mode & 0o777).toString(8));
console.log('mtime :', s.mtime.toISOString().slice(0, 19));
await chmod('package.json', 0o644); // owner rw, others read-only
console.log('chmod :', (await stat('package.json')).mode.toString(8));
try {
await access('package.json', constants.R_OK | constants.W_OK);
console.log('access: readable and writable');
} catch { console.log('access: denied'); }
console.log('gone :', statSync('nope.json', { throwIfNoEntry: false }));size : 16 bytes, dir? false mode : 666 mtime : 2026-09-17T10:34:56 chmod : 100666 access: readable and writable gone : undefined
That chmod result is the lesson. The code asked for 0o644 and the file came back 0o666. Windows has no POSIX permission bits, so Node maps chmod onto the single read-only attribute and any mode with a write bit reads back as writable for everyone. Test permission logic on Linux, not on your laptop.
stats.mode packs the file type into its high bits, which is why the raw value prints as 100666: 0o100000 means "regular file." Mask with & 0o777 for permissions alone. Timestamps arrive as Date objects (mtime, atime, ctime, birthtime) alongside millisecond numbers (mtimeMs and friends); pass { bigint: true } when nanosecond precision matters for cache invalidation, and note that Node 26.2.0 added Temporal.Instant accessors too. statSync throws on a missing path unless you pass { throwIfNoEntry: false }.