Understanding the file system
A file system has three ordinary pieces. A file is a sequence of bytes. A directory holds names for files and other directories. A path tells an operation which sequence of directory names to follow. The path is not the file, in the same way that an address is not the building at that address.
Create programs/understanding-the-file-system.test.ts for this reference and keep it open. Use it for the runnable examples, adding or replacing code when the page asks you to.
what a file system is
A file system is the operating system's service for organizing named data and applying operations to it. Its backing storage might be an SSD, a removable drive, a network share, or memory; the broad programming model remains a hierarchy of entries reached through paths. “File system” can refer both to that model and to a particular implementation or mounted volume that supplies it.
The main concepts answer different questions:
| Concept | Meaning | Question it answers |
|---|---|---|
| regular file | an ordered sequence of bytes plus metadata | what data is stored? |
| directory | a collection that associates child names with entries | which names are available here? |
| entry | one named file, directory, link, or platform-specific object | what kind of object does this name reach? |
| path | a sequence of names used for lookup | how should an operation find an entry? |
| metadata | facts such as kind, byte size, timestamps, and access mode | what does the system know about the entry? |
| root or volume | a top-level starting point for path lookup | which filesystem namespace is being searched? |
| open handle | a live operating-system resource referring to an opened file | how can several operations share position and ownership? |
Consider a small hierarchy:
project/
├── notes.txt
└── archive/
└── old.txt
project, notes.txt, archive, and old.txt are names stored by directories. project/archive/old.txt is a path: an instruction to look up project, then archive inside it, then old.txt inside that. The path string contains no file data. Renaming changes how an entry is reached; copying creates another file with its own bytes; removing makes a selected name stop reaching its entry.
A file system stores bytes, not meanings. It does not inherently know that some bytes are UTF-8 text, JSON, an image, or executable code. The program selects an encoding or parser. A suffix such as .txt or .json is a naming convention that helps programs and people choose an interpretation; it does not transform the bytes or guarantee their format.
paths, roots, and process context
An absolute path includes a filesystem starting point. A relative path needs outside context, normally the process's current working directory. Unix-like systems commonly begin absolute paths at /. Windows can use drive roots and UNC paths. Mounted and network filesystems can make separate storage appear inside a larger hierarchy.
Separators are only the most visible platform difference. Case sensitivity, reserved names, supported metadata, maximum component lengths, link behavior, and replacement rules can also differ by filesystem and operating system. @std/path supplies the host's path syntax, but it cannot make every filesystem guarantee identical.
A path is therefore a lookup instruction, not a permanent identity. The entry at a path can be renamed, removed, or replaced between two calls. A symbolic link can redirect lookup. Code that performs readDir, then stat, then readFile has made three separate observations—not one transactional snapshot.
operations and open handles
Most convenience functions perform one complete operation: find a path, read or change the selected entry, then release any internal resource. Deno.open is different. It returns an FsFile, an open handle owned by the program. The handle has a byte position, can participate in several reads or writes, and consumes an operating-system resource until closed.
Successful completion has a deliberately narrow meaning. A read returns what was observed during that operation. A write has been accepted by the operating system, but ordinary completion does not by itself promise survival through sudden power loss. An operation also does not prevent another process from changing the same directory or file immediately afterward. Later sections make those boundaries observable.
the layers around a Deno filesystem call
A Deno filesystem request crosses several layers:
your TypeScript
↓
Deno capability check
↓
operating-system identity and access rules
↓
the filesystem that owns the selected path
Deno first decides whether the process was granted the relevant read or write capability. The operating system can then apply the user's permissions and the filesystem's own rules. Finally, the filesystem performs or refuses the requested lookup and operation. This is why “the path exists,” “Deno granted access,” and “the operating system allowed the operation” are distinct facts.
Deno supplies operations such as readTextFile, readFile, writeTextFile, writeFile, makeTempDir, mkdir, readDir, stat, copyFile, rename, remove, and open. The operating system supplies the storage underneath them. Deno's capability check is the additional boundary between the program and that storage.
when a file is a good fit
Files fit data that naturally behaves like a named artifact: source code, configuration, templates, exported reports, logs, media, caches, and documents exchanged with users or other tools. They are inspectable with ordinary editors and command-line programs, and their directory layout can be useful organization.
A file is not automatically a database. When an application needs queries across many records, coordinated updates to several values, strong transaction guarantees, or many concurrent writers, a database may provide the narrower and safer abstraction. When data should disappear with the process, an in-memory value may be simpler. The useful question is not “can this be placed in a file?” but “do names, byte contents, and filesystem operations match the guarantees this data needs?”
This entry builds paths first, then reads and changes entries, and ends with an open file whose position survives from one operation to the next. Every change happens in a temporary directory beside the test file. The helper at the top creates that directory, hands it to one test, and removes it in finally, including when an assertion fails. Nothing below needs to experiment on a file you care about.
The path functions come from Deno's standard @std/path package. Unlike a hard-coded /, they use the path rules of the operating system running the program.
how to read an operation's shape
Each operation below gets a compact shape before or beside its first use. A shape such as Deno.readTextFile(path: string | URL, options?: Deno.ReadFileOptions): Promise<string> is the function's contract in miniature.
- Inside the parentheses are the inputs.
path: string | URLmeans either representation is accepted. - A
?marks an optional input. The call may omitoptionsentirely. ...pathSegments: string[]accepts zero or more string arguments rather than one array argument.- The type after the final colon is the result.
Promise<string>meansawaitproduces a string;Promise<void>meansawaitonly confirms completion and produces no useful value. AsyncIterable<Deno.DirEntry>is consumed withfor await, yielding one entry at a time.
The shapes here keep the names qualified when that prevents ambiguity: Deno's runtime types are written as Deno.FileInfo and Deno.WriteFileOptions, while imported @std/path functions keep their short names. Optional fields shown inside an options object affect one call; they do not reconfigure Deno globally.
The test support has a smaller role:
assert(value: unknown): asserts valuerequires a truthy value and narrows its type afterward.assertEquals<T>(actual: T, expected: T): voidcompares two values and throws when they differ.assertStringIncludes(actual: string, expected: string): voidrequires the first string to contain the second.assertRejects(fn: () => Promise<unknown>, ErrorClass?): Promise<Error>requires the asynchronous function to reject, optionally with a particular error class, and resolves to the captured error.
These functions observe the filesystem examples; they do not perform filesystem work themselves.
Deno.test(name: string, fn: () => void | Promise<void>): void registers one independently reported test. Synchronous callbacks finish when they return; asynchronous callbacks finish when their returned promise settles. The locally defined withScratch(run: (root: string) => Promise<void>): Promise<void> is not a Deno API: it creates a temporary root, supplies that path to run, and guarantees cleanup around the callback.
a relative path starts at the current working directory
Start with no file operation at all. Three call shapes answer what a relative path means:
Deno.cwd(): stringtakes no arguments and returns the process's current working directory as an absolute path string. Choose it when the launch location is intentionally part of the program's input.resolve(...pathSegments: string[]): stringcombines and normalizes segments into an absolute path. If the supplied segments do not establish an absolute starting point, it usesDeno.cwd(). Choose it when the result itself needs to be absolute.join(path: string | URL, ...paths: string[]): stringcombines and normalizes path pieces without promising to make a relative result absolute. Choose it when the program already has a base path and one or more child names.
cwd, resolve, and join only compute strings; none checks whether the resulting path exists. cwd asks Deno for process state but needs no filesystem permission, while the two @std/path functions do not access the operating system's stored entries at all.
The first block also defines the scratch helper used later. Deno.makeTempDir(options?: Deno.MakeTempOptions): Promise<string> creates a uniquely named directory and resolves to its full path. Its dir, prefix, and suffix fields control where and how the name is formed; the helper sets dir and prefix. It is a good fit for isolated work that must not collide with another run, and it requires write permission for the parent directory.
Cleanup uses Deno.remove(path: string | URL, options?: Deno.RemoveOptions): Promise<void>. It removes one file or directory and resolves without a value. { recursive: true } permits removal of a directory's descendants too, which is appropriate here only because the target was just created by makeTempDir. The finally block makes the caller responsible for cleanup whether its work succeeds or throws.
import {
assert,
assertEquals,
assertRejects,
assertStringIncludes,
} from "@std/assert";
import { basename, dirname, fromFileUrl, join, relative, resolve } from "jsr:@std/path@1";
const programDirectory = fromFileUrl(new URL(".", import.meta.url));
async function withScratch(
run: (root: string) => Promise<void>,
): Promise<void> {
const root = await Deno.makeTempDir({
dir: programDirectory,
prefix: "file-system-",
});
// Uncomment the next line to see where the file system changes happen.
// console.log(root);
try {
await run(root);
} finally {
await Deno.remove(root, { recursive: true });
}
}
Deno.test(
"a relative path starts at the current working directory",
() => {
const actual = resolve("programs", "notes.txt");
const expected = join(Deno.cwd(), "programs", "notes.txt");
assertEquals(actual, expected);
},
);
Run it from the root of the examples project:
deno test --no-prompt programs/understanding-the-file-system.test.ts
Check programs/understanding-the-file-system.test.ts
running 1 test from ./programs/understanding-the-file-system.test.ts
a relative path starts at the current working directory ... ok (0ms)
ok | 1 passed | 0 failed (2ms)
actual and expected are identical. Follow the two expressions.
resolve("programs", "notes.txt")receives no absolute starting point, so it starts at the process's current working directory and appends the two segments.Deno.cwd()returns that starting directory explicitly.join(Deno.cwd(), "programs", "notes.txt")builds the same absolute path one piece at a time.- The assertion passes because both expressions name the same location.
The current working directory belongs to the running process. It is usually the directory where you typed deno test, which here is the examples project. It is not automatically the directory containing the TypeScript file. Run the same file from a different directory and the same relative path can name a different place.
You can now answer the first question about any relative path: relative to what? Unless another API says otherwise, the answer is the current working directory.
a file URL stays anchored to its module
Code often needs a file shipped beside the module, regardless of where the command was launched. import.meta.url supplies the module's own location for that case:
new URL(input: string | URL, base?: string | URL): URLconstructs a URL object. With a relativeinputand abase, it resolves the input against that base. Choose this form for an asset whose location belongs to a module rather than to the shell that launched it.fromFileUrl(url: string | URL): stringconverts afile:URL into a path string using the host operating system's spelling. Choose it at the boundary where URL-based module location meets a path-only operation.dirname(path: string | URL): stringreturns the directory portion of a path, whilebasename(path: string | URL, suffix?: string): stringreturns its last portion. Choose them to inspect path structure; neither reads a directory or asks whether the path exists.
import.meta.url is a string value supplied by the module system, not a function call. The URL constructor keeps the location in URL form; fromFileUrl crosses into filesystem-path form only when a path function needs it.
Deno.test("a file URL stays anchored to its module", () => {
const modulePath = fromFileUrl(import.meta.url);
const sibling = new URL("./notes.txt", import.meta.url);
assertEquals(dirname(fromFileUrl(sibling)), dirname(modulePath));
assertEquals(basename(fromFileUrl(sibling)), "notes.txt");
});
a file URL stays anchored to its module ... ok (0ms)
import.meta.url is a file: URL naming the current module. new URL("./notes.txt", import.meta.url) resolves the first URL against the second, so sibling names notes.txt in the module's directory. Neither expression asks for Deno.cwd().
Most Deno file operations accept either a path string or a URL, so a module-relative read can pass sibling directly. fromFileUrl is present here because dirname and basename are path functions: it converts the file URL into the path syntax used by this operating system.
Use a relative path when the caller's working directory should choose the location. Use a file URL based on import.meta.url when the module's own directory should choose it. That distinction prevents the common program that reads its data correctly from one terminal and loses it from another.
join follows the host's path rules
join(path: string | URL, ...paths: string[]): string takes a first path plus any number of additional string segments, normalizes separators and . or .. segments, and returns one path string. It is the ordinary choice for attaching known child names to a known base. It does not read the filesystem, create directories, or prove that the result stays within a security boundary.
Paths are made of segments, and the separator between those segments differs. Keep the three segments unchanged and ask join to assemble them:
Deno.test("join follows the host's path rules", () => {
const entry = join("archive", "2026", "notes.txt");
assertEquals(basename(entry), "notes.txt");
assertEquals(basename(dirname(entry)), "2026");
});
join follows the host's path rules ... ok (0ms)
The test does not assert that entry equals "archive/2026/notes.txt". That spelling is correct on macOS and Linux, while Windows normally uses backslashes. The test instead asks structural questions that have the same answer everywhere:
basename(entry)selects the final segment,"notes.txt".dirname(entry)removes that final segment.- The second
basenameselects the new final segment,"2026".
String concatenation such as directory + "/" + name smuggles one operating system's separator into the program. join(directory, name) states the actual intent and lets @std/path supply the spelling.
resolve does not keep a path inside an intended root
resolve(...pathSegments: string[]): string returns an absolute, normalized path. relative(from: string, to: string): string asks the inverse question: which relative path leads from from to to? Choose resolve to establish an absolute location and relative to describe the relationship between two locations. Both are lexical calculations over path strings, not access checks.
Portable path construction and path authorization are different jobs. Suppose public is the directory an application intends to expose, while the remaining segments came from outside the program:
Deno.test("resolve does not keep a path inside an intended root", () => {
const root = resolve("public");
const requested = resolve(root, "..", "secret.txt");
assertEquals(relative(root, requested), join("..", "secret.txt"));
assertEquals(dirname(requested), dirname(root));
});
resolve does not keep a path inside an intended root ... ok (0ms)
resolve starts at the absolute root, processes .. by moving to its parent, and then adds secret.txt. The result is a well-formed, portable path, but it is not beneath root. relative(root, requested) exposes that relationship: the first segment is ...
Neither join nor resolve promises containment. When path segments are untrusted, the application needs an explicit input policy before it performs a filesystem operation. Prefer a constrained filename or identifier over an arbitrary path where possible. A lexical containment check is only one layer: operating-system case rules and existing symbolic links can affect which entry a path eventually reaches. The separate Symbolic links reference will handle that deeper lookup problem.
readTextFile needs read permission for the path
Deno.readTextFile(path: string | URL, options?: Deno.ReadFileOptions): Promise<string> reads one entire file, decodes its bytes as UTF-8, and resolves to the resulting string. Choose it for a small, bounded text file such as configuration, a template, or a fixture. It needs read permission for the path. The optional signal can cancel a read that is still pending; it does not make the result incremental.
The first real file operation reads the test file itself. new URL(import.meta.url) turns the module URL string into the URL object accepted by readTextFile:
Deno.test("readTextFile needs read permission for the path", async () => {
const source = await Deno.readTextFile(new URL(import.meta.url));
assertStringIncludes(source, "Understanding the file system");
});
Save while the earlier command is still running with no permissions:
readTextFile needs read permission for the path ... FAILED (1ms)
error: NotCapable: Requires read access to
"/examples/programs/understanding-the-file-system.test.ts",
run again with the --allow-read flag
const source = await Deno.readTextFile(new URL(import.meta.url));
^
The file plainly exists because Deno loaded it as the test module. Loading the known module graph and letting running code inspect arbitrary files are separate acts. Deno allowed the first and refused the second.
NotCapable is Deno's capability error. It says the program was not granted the required Deno permission, before the operating system was asked whether its own user account could read the file. If Deno grants an operation but the operating-system account, file mode, or access-control rules refuse it, the operation can instead reject with Deno.errors.PermissionDenied. Both errors refuse access, but at different layers; a program should not report every refusal as a missing Deno flag.
Grant read access only to the programs directory and run again:
deno test --no-prompt --allow-read=programs \
programs/understanding-the-file-system.test.ts
readTextFile needs read permission for the path ... ok (0ms)
The operation now returns one string containing the whole UTF-8-decoded file, and the assertion finds the test name inside it. The path-scoped flag matters: ordinary read requests whose checked paths are under programs are authorized, while arbitrary paths are not. --allow-read without a path would grant much more than this lesson needs, and -A would turn every permission check off.
A scoped grant is still not a filesystem jail. When path lookup traverses an existing symbolic link, Deno checks permission at the link's location rather than the target's location. A link under an allowed directory can therefore lead to a target outside that directory. Do not rely on a scoped flag to contain attacker-controlled links; path permission and link-aware containment are separate security decisions.
--no-prompt keeps the experiment repeatable. Instead of pausing to ask an interactive question, Deno either finds the permission in the command or throws the error the test can expose.
read permission does not grant write permission
Deno.writeTextFile(path: string | URL, data: string | ReadableStream<string>, options?: Deno.WriteFileOptions): Promise<void> encodes text as UTF-8 and writes it to a path. Choose it when the program's input is text and UTF-8 is the intended storage encoding. The string form fits small, already-available text; the stream form can supply text over time and belongs with the later streams reference. A successful promise carries no file or content back—only completion.
Reading and writing are separate capabilities. writeTextFile needs write permission for its destination; when { create: false } is used, Deno also requires read permission. A read grant alone does not imply either form of write capability. Keep the read flag, call the scratch helper for the first time, and try to create a file:
Deno.test("read permission does not grant write permission", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "hello");
assertEquals(await Deno.readTextFile(note), "hello");
});
});
read permission does not grant write permission ... FAILED (0ms)
error: NotCapable: Requires write access to "/examples/programs",
run again with the --allow-write flag
const root = await Deno.makeTempDir({
^
The failure happens before note.txt is written. withScratch first asks makeTempDir to create a new directory under programs, and directory creation is a write. Read access has no authority over it.
Add a write grant with the same scope:
deno test --no-prompt \
--allow-read=programs \
--allow-write=programs \
programs/understanding-the-file-system.test.ts
read permission does not grant write permission ... ok (1ms)
Three writes now happen: makeTempDir creates the scratch directory, writeTextFile creates the note, and remove deletes the scratch directory in finally. The read-back in the middle uses the independent read grant. A real program can therefore be read-only, write-only, or allowed to do both, and its command should say which one it is.
Keep this command running for the rest of the entry. Every path the examples touch stays under programs, so the two scoped grants remain sufficient.
writeTextFile creates a missing file
The options used throughout the next three steps are fields of Deno.WriteFileOptions:
create?: booleancontrols whether a missing file may be created and defaults totrue.append?: booleanplaces new data after existing data instead of replacing from the beginning and defaults tofalse.createNew?: booleanrequires the destination to be absent and defaults tofalse.
Other options can set a new file's mode or provide an abort signal, but they do not change the three creation choices being compared here. Use the default for generated output that may be created or replaced, append for a continuing log-like sequence, and createNew when collision must be an error.
The scratch helper gives this step a new empty directory. Confirm that the path is absent, write to it, and read the result. The check uses Deno.stat(path: string | URL): Promise<Deno.FileInfo>, which returns metadata for an existing path and rejects with Deno.errors.NotFound here. Its fuller use case appears in the metadata section:
Deno.test("writeTextFile creates a missing file", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await assertRejects(() => Deno.stat(note), Deno.errors.NotFound);
await Deno.writeTextFile(note, "hello");
assertEquals(await Deno.readTextFile(note), "hello");
});
});
writeTextFile creates a missing file ... ok (1ms)
The first stat asks the file system to describe note.txt. There is no entry under that name, so the promise rejects with Deno.errors.NotFound, and assertRejects records that expected absence.
writeTextFile then receives a path that does not exist. Its default is create: true, so the operation creates the entry, encodes "hello" as UTF-8 bytes, writes those bytes, and closes the file. The final read produces "hello", which is direct evidence that the new path now names those contents.
The write resolves to undefined; it does not return the file or the text. When the result matters, inspect the state that the operation changed, as this assertion does.
writeTextFile replaces existing contents by default
Creating a missing entry is the safe-looking case. The default for an existing entry is the one to predict carefully. Write twice, then predict the read:
Deno.test("writeTextFile replaces existing contents by default", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "first");
await Deno.writeTextFile(note, "second");
assertEquals(await Deno.readTextFile(note), "firstsecond");
});
});
writeTextFile replaces existing contents by default ... FAILED (8ms)
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- second
+ firstsecond
The second write did not continue after the first. It replaced the file's entire previous contents, so the read returns only "second". These calls use the string form of writeTextFile, so each receives its whole input string; the next readTextFile always returns the whole current file as one string.
Correct the expectation:
Deno.test("writeTextFile replaces existing contents by default", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "first");
await Deno.writeTextFile(note, "second");
assertEquals(await Deno.readTextFile(note), "second");
});
});
writeTextFile replaces existing contents by default ... ok (0ms)
This default is useful for regenerated configuration and output files. It is dangerous when the existing contents must survive. The next two steps spell those two intentions explicitly: append when the old bytes should remain before the new ones, and createNew when any existing entry should make the operation fail.
One more guarantee is deliberately absent. When await Deno.writeTextFile(...) resolves, the write operation has completed from the program's point of view; that alone is not a promise that the bytes would survive an immediate power loss. file.sync(): Promise<void> on an open FsFile asks the operating system to flush pending file data and metadata to storage. A crash-safe replacement protocol also has to account for temporary files, renaming, directories, and platform guarantees, so it belongs in a separate Reliable file updates reference.
append adds content after the existing contents
Only one condition changes. The second write receives { append: true }:
Deno.test("append adds content after the existing contents", async () => {
await withScratch(async (root) => {
const log = join(root, "log.txt");
await Deno.writeTextFile(log, "first\n");
await Deno.writeTextFile(log, "second\n", { append: true });
assertEquals(await Deno.readTextFile(log), "first\nsecond\n");
});
});
append adds content after the existing contents ... ok (0ms)
The original "first\n" remains, and the second string begins after it. append does not invent a delimiter. Both newline characters are present because both strings supplied one. A log writer that wants one record per line must include the newline in every record it appends.
The option changes where the write begins, not what a string means. Deno still encodes the new string as UTF-8 bytes before adding it.
createNew refuses to replace an existing entry
Sometimes neither replacing nor appending is acceptable. A newly claimed output name, lock file, or one-time export should fail if anything already has that name:
Deno.test("createNew refuses to replace an existing entry", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "original");
await assertRejects(
() => Deno.writeTextFile(note, "replacement", { createNew: true }),
Deno.errors.AlreadyExists,
);
assertEquals(await Deno.readTextFile(note), "original");
});
});
createNew refuses to replace an existing entry ... ok (0ms)
AlreadyExists is the intended result, and the last assertion proves the failed attempt left the original contents untouched.
Checking with stat and then performing an ordinary write cannot make the same guarantee. Another part of the program could create the path between those two operations. createNew asks the file system to make the decision as part of the creation itself: create this entry only if the name is still unused. Use it when replacing would be a bug, rather than relying on a check whose answer can become stale.
readFile returns bytes rather than text
Deno.readFile(path: string | URL, options?: Deno.ReadFileOptions): Promise<Uint8Array> reads an entire file without decoding it and resolves to a byte array. Choose it for images, archives, encoded formats, or any small file whose exact bytes matter. Like readTextFile, it needs read permission and can receive an abort signal.
Its writing counterpart is Deno.writeFile(path: string | URL, data: Uint8Array | ReadableStream<Uint8Array>, options?: Deno.WriteFileOptions): Promise<void>. Choose the byte-array form for small binary data already in memory and the stream form when bytes arrive over time. It follows the same create, append, and create-new choices as writeTextFile, but performs no text encoding.
The opening model said a file contains bytes. So far readTextFile has hidden that layer by decoding UTF-8. Read the same entry both ways:
Deno.test("readFile returns bytes rather than text", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "Aé");
const text = await Deno.readTextFile(note);
const bytes = await Deno.readFile(note);
assertEquals(text.length, 2);
assertEquals(bytes.length, 3);
assertEquals([...bytes], [65, 195, 169]);
});
});
readFile returns bytes rather than text ... ok (0ms)
One file, two views.
writeTextFileencodes"Aé"as UTF-8.Abecomes the byte65, whileéneeds the two bytes195and169.readTextFiledecodes those three bytes back into the two-code-unit JavaScript string"Aé", sotext.lengthis2.readFileperforms no text decoding. It returns aUint8Arrayholding all three bytes, sobytes.lengthis3.
This is a fourth count beside the code units, code points, and grapheme clusters on the text and characters page: encoded bytes on storage. Use the text functions when the file format is UTF-8 text. Use readFile and writeFile for images, compressed data, or any format whose bytes must survive without text interpretation.
Both read helpers wait for the entire file and hold the complete result in memory before resolving. async lets other work proceed while Deno waits for the operating system; it does not make the memory use incremental. These helpers fit small, bounded files. An FsFile or stream fits data that is large, unbounded, or should be processed piece by piece.
Deno also supplies synchronous counterparts. readTextFileSync(path: string | URL, options?: Deno.ReadFileOptions): string returns the string directly, writeTextFileSync(path: string | URL, data: string, options?: Deno.WriteFileOptions): void completes or throws before the next statement, and statSync(path: string | URL): Deno.FileInfo returns metadata directly. Removing the Promise removes the need for await, but it also blocks the current JavaScript thread until the operation finishes. That can be a reasonable trade for a short startup task or one-shot script; the asynchronous forms are the usual default when a server or tool has other work to keep serving.
recursive mkdir creates missing parent directories
Deno.mkdir(path: string | URL, options?: Deno.MkdirOptions): Promise<void> creates a directory and resolves without a value. It needs write permission. Choose the default call when exactly one new directory is expected and an existing name should be an error; choose { recursive: true } when missing parents should also be created and an already-existing final directory is acceptable. The optional mode requests initial Unix permissions and is not a portable substitute for an application's access policy.
A path may name several directories that do not exist yet. Ask ordinary mkdir to create only the last one:
Deno.test(
"recursive mkdir creates missing parent directories",
async () => {
await withScratch(async (root) => {
const nested = join(root, "archive", "2026");
await assertRejects(() => Deno.mkdir(nested), Deno.errors.NotFound);
await Deno.mkdir(nested, { recursive: true });
assert((await Deno.stat(nested)).isDirectory);
});
},
);
recursive mkdir creates missing parent directories ... ok (1ms)
The first call fails because archive is absent. To create 2026, the file system first needs a directory in which the name 2026 can live, and there is no such directory yet.
The second call keeps the path unchanged and adds { recursive: true }. Deno creates archive, then creates 2026 inside it. stat confirms that the final path names a directory.
Here recursive describes creation along the path, not files appearing inside the new directory. Both directories begin empty. The same word means something more destructive when passed to remove, which gets its own step after the safer operations.
readDir yields names and kinds for immediate children
Deno.readDir(path: string | URL): AsyncIterable<Deno.DirEntry> returns an asynchronous iterable, not a promise containing an array. A for await loop requests each immediate child as a DirEntry with name, isFile, isDirectory, and isSymlink fields. Choose it for one-level directory listings or as the first operation in traversal code. It needs read permission for the directory, yields no guaranteed order, and does not recurse.
The example constructs the array itself. entries.push(value): number appends one projected entry and returns the array's new length, which this code does not need. entries.sort(compareFn): typeof entries rearranges that same array in place; the comparison returns a negative number for “left first,” a positive number for “right first,” and zero for equal ordering. Choose an explicit comparison whenever filesystem order must become stable presentation order.
A directory is not text, so readTextFile is the wrong question for it. readDir answers with one DirEntry for each immediate child:
Deno.test(
"readDir yields names and kinds for immediate children",
async () => {
await withScratch(async (root) => {
await Deno.writeTextFile(join(root, "note.txt"), "hello");
await Deno.mkdir(join(root, "archive"));
await Deno.writeTextFile(join(root, "archive", "old.txt"), "old");
const entries: Array<{
name: string;
isFile: boolean;
isDirectory: boolean;
}> = [];
for await (const entry of Deno.readDir(root)) {
entries.push({
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
});
}
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
assertEquals(entries, [
{ name: "archive", isFile: false, isDirectory: true },
{ name: "note.txt", isFile: true, isDirectory: false },
]);
});
},
);
readDir yields names and kinds for immediate children ... ok (1ms)
There are three facts in the two returned objects, all about the same directory listing.
entry.nameis one name such as"note.txt", not the full path. To operate on the entry later, join that name back ontoroot.isFileandisDirectoryclassify the entry without reading its contents. ADirEntryalso hasisSymlink, left for a separate Symbolic links reference because links change how path lookup proceeds.old.txtis absent from the result because it is a child ofarchive, not an immediate child ofroot.readDirlists one directory level; it does not walk a whole tree.
The for await loop consumes an asynchronous iterable. It asks readDir for entries one at a time and permits Deno to wait for the operating system between them. The entry order is not guaranteed, so the example sorts by name before asserting. Code that presents a stable listing must choose and apply an order of its own.
a directory entry can change after it is listed
A directory listing is an observation, not a reservation. Remove the only listed entry before asking for its metadata:
Deno.test("a directory entry can change after it is listed", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "hello");
let listed: Deno.DirEntry | undefined;
for await (const entry of Deno.readDir(root)) {
listed = entry;
break;
}
assert(listed);
const listedName = listed.name;
await Deno.remove(note);
await assertRejects(
() => Deno.stat(join(root, listedName)),
Deno.errors.NotFound,
);
});
});
a directory entry can change after it is listed ... ok (0ms)
listed proves that note.txt existed while readDir visited it. It does not keep the file alive. The deliberate remove stands in for any other task or process that changes the directory before the next operation, so stat correctly reports NotFound.
The same gap exists even when the two calls are adjacent. Code that inventories a live directory should interpret NotFound from the later operation as “this entry changed while I was looking,” while still rethrowing unrelated errors. Even then, the returned inventory is a best-effort observation: another change can happen immediately after it is built.
stat reports type and byte size
Deno.stat(path: string | URL): Promise<Deno.FileInfo> reads metadata for the entry reached through a path. The result includes kind flags, byte size, timestamps where available, and platform-specific fields. Choose it when the metadata itself is needed—for example, to display size or distinguish a file from a directory—not merely as a preliminary existence check. It needs read permission and follows symbolic links to describe their targets; a later Symbolic links reference will contrast lstat.
DirEntry gives enough information for a listing. stat asks for fuller information about one path:
Deno.test("stat reports type and byte size", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
const archive = join(root, "archive");
await Deno.writeTextFile(note, "Aé");
await Deno.mkdir(archive);
const noteInfo = await Deno.stat(note);
const archiveInfo = await Deno.stat(archive);
assert(noteInfo.isFile);
assertEquals(noteInfo.size, 3);
assert(archiveInfo.isDirectory);
});
});
stat reports type and byte size ... ok (0ms)
stat returns a Deno.FileInfo. The booleans identify the kind of entry, and size reports bytes. The text step already established why the file containing "Aé" occupies three bytes even though the JavaScript string has length two.
FileInfo also carries modification, access, and creation times where the operating system provides them, plus platform-specific fields such as mode and ownership. Those facts do not make stable cross-platform assertions: some times may be null, a directory's reported size is not the total size of its contents, and ownership has different meanings across systems. Ask for the particular metadata the program needs instead of treating every field as equally portable.
One tempting use of stat is asking whether a path exists before performing another operation. That creates two filesystem operations and leaves a gap in which the answer can change. The next step handles absence at the operation that matters.
catch only NotFound when absence is expected
A missing optional file is not necessarily a program failure. The safe fallback recognizes that one error and lets every other error continue outward:
Deno.test("catch only NotFound when absence is expected", async () => {
await withScratch(async (root) => {
async function readOptional(path: string): Promise<string | undefined> {
try {
return await Deno.readTextFile(path);
} catch (error) {
if (error instanceof Deno.errors.NotFound) return undefined;
throw error;
}
}
const present = join(root, "present.txt");
await Deno.writeTextFile(present, "here");
assertEquals(await readOptional(present), "here");
assertEquals(await readOptional(join(root, "missing.txt")), undefined);
});
});
catch only NotFound when absence is expected ... ok (0ms)
Trace the two calls.
readOptional(present)completes itstry, so the function returns"here"and never enterscatch.- The missing read rejects, so execution enters
catchwith the thrown error. instanceof Deno.errors.NotFoundidentifies the expected absence, and only that class becomesundefined.- Any other error is thrown again. A missing read grant still surfaces as
NotCapable; trying to read a directory still surfaces as the relevant filesystem error.
A broad catch that returned undefined for everything would make “the file is absent” indistinguishable from “the program was forbidden to read it.” Catch the condition the program can answer, and preserve conditions it cannot.
This pattern also avoids a separate existence check. Attempt the read, then interpret NotFound from that read. For creation, use the corresponding single-operation guarantee from createNew refuses to replace an existing entry.
copyFile makes an independent second file
Deno.copyFile(fromPath: string | URL, toPath: string | URL): Promise<void> copies one source file's contents to one destination path and resolves without returning the copy. It needs read permission for the source and write permission for the destination. Choose it for a single-file duplicate; by default it may create or overwrite the destination, so use an unused destination when replacement is not intended. It does not copy a directory tree.
Copy a file, then change the source:
Deno.test("copyFile makes an independent second file", async () => {
await withScratch(async (root) => {
const source = join(root, "source.txt");
const copy = join(root, "copy.txt");
await Deno.writeTextFile(source, "first");
await Deno.copyFile(source, copy);
await Deno.writeTextFile(source, "changed");
assertEquals(await Deno.readTextFile(source), "changed");
assertEquals(await Deno.readTextFile(copy), "first");
});
});
copyFile makes an independent second file ... ok (1ms)
The destination received the source's bytes as they existed during the copy. The later whole-file write reaches source.txt only, so copy.txt still contains "first". Two paths now name two independent files; there is no continuing connection that sends future writes from one to the other.
copyFile copies one file. It is not a recursive directory-copy operation, and this example does not suggest that it is. Copying a tree requires deciding how to handle nested entries, links, collisions, and errors partway through, which deserves a separate operation rather than a hidden loop here.
rename gives an entry a new path
Deno.rename(oldPath: string | URL, newPath: string | URL): Promise<void> moves one filesystem entry from the old path to the new path and resolves without returning the entry. Choose it when identity should move rather than be duplicated: renaming within a directory and moving between directories use the same call. It needs read and write permission for the affected paths. Existing destinations and moves across filesystem boundaries have platform-specific restrictions, so the ordinary portable case uses an unused destination on the same filesystem.
Copying leaves both names in place. Renaming makes the old path stop naming the entry and makes the new path name it:
Deno.test("rename gives an entry a new path", async () => {
await withScratch(async (root) => {
const oldPath = join(root, "draft.txt");
const newPath = join(root, "published.txt");
await Deno.writeTextFile(oldPath, "ready");
await Deno.rename(oldPath, newPath);
await assertRejects(() => Deno.stat(oldPath), Deno.errors.NotFound);
assertEquals(await Deno.readTextFile(newPath), "ready");
});
});
rename gives an entry a new path ... ok (0ms)
After rename, draft.txt is absent and published.txt contains the same "ready" bytes. This is a move when the two paths have different parent directories and a rename when only the final name changes; Deno.rename handles both forms.
The destination is deliberately unused. Replacing an existing destination and moving across filesystem boundaries have operating-system-specific restrictions, so portable code should not infer those wider guarantees from this ordinary case. The earned fact is narrower: within this scratch directory, a successful rename transfers the entry from one unused path to another.
a non-empty directory needs recursive removal
Deno.remove(path: string | URL, options?: Deno.RemoveOptions): Promise<void> removes one file or directory and resolves without a return value. Choose the default for a file, symbolic link, or empty directory. { recursive: true } extends the operation to a directory's entire descendant tree. It needs write permission and is irreversible at the API level, so the target deserves validation before the call.
Removal is the first operation here that can destroy several entries at once, so the ordinary call refuses to do that silently:
Deno.test("a non-empty directory needs recursive removal", async () => {
await withScratch(async (root) => {
const archive = join(root, "archive");
await Deno.mkdir(archive);
await Deno.writeTextFile(join(archive, "note.txt"), "hello");
await assertRejects(() => Deno.remove(archive));
await Deno.remove(archive, { recursive: true });
await assertRejects(() => Deno.stat(archive), Deno.errors.NotFound);
});
});
a non-empty directory needs recursive removal ... ok (0ms)
The first removal rejects because archive still contains note.txt. The test intentionally does not pin a more specific error class or message there; operating systems report a non-empty directory differently, while the rejection itself is the portable behavior this step needs.
{ recursive: true } authorizes Deno to remove the contents and then the directory. The final stat receives NotFound, confirming that the whole subtree is gone.
This option should look consequential. Resolve and inspect the target before using it in application code, never build a recursive-removal target from an unchecked empty string, and grant write access only where deletion is intended. The example can be direct because archive was created inside a unique scratch directory seconds earlier and finally was going to remove that same scratch directory anyway.
an open file remembers its byte position
Deno.open(path: string | URL, options?: Deno.OpenOptions): Promise<Deno.FsFile> opens a live handle to a file. Its options select capabilities such as read, write, append, create, createNew, and truncate; those choices determine which Deno permissions are required. Choose an FsFile when several operations should share one open resource, when byte position matters, or when the program needs partial reads, streaming, locking, or explicit flushing. Every successful open must eventually be closed.
The handle method file.read(buffer: Uint8Array): Promise<number | null> asks for bytes to be copied into caller-provided storage. It resolves to the number actually copied, which may be smaller than the buffer, or to null at end-of-file; 0 is possible and is not end-of-file. Choose it when the program wants bounded chunks and is prepared to manage the current byte position.
The example's new Uint8Array(2) allocates a two-byte buffer. decoder.decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string converts bytes into text using the TextDecoder's encoding, UTF-8 by default. In general, decode only buffer.subarray(0, count) because bytes after count were not filled by that read; subarray(start, end?): Uint8Array returns a view over that selected range without copying its bytes. This example first proves that count is exactly 2, so decoding the whole two-byte buffer is correct.
The whole-file helpers open, transfer, and close on each call. Deno.open instead returns an FsFile, a live resource that can participate in several operations:
Deno.test("an open file remembers its byte position", async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "planet");
using file = await Deno.open(note, { read: true });
const buffer = new Uint8Array(2);
const decoder = new TextDecoder();
assertEquals(await file.read(buffer), 2);
assertEquals(decoder.decode(buffer), "pl");
assertEquals(await file.read(buffer), 2);
assertEquals(decoder.decode(buffer), "an");
});
});
an open file remembers its byte position ... ok (0ms)
The same two-byte buffer participates in both reads, but the results differ because the file carries a current byte position.
Deno.open(note, { read: true })opens the file with its position at byte zero.- The first
readcopies two bytes intobuffer, returns the count2, and advances the position by two. Decoding those bytes produces"pl". - The second
readstarts from that new position, copies the next two bytes over the buffer's old contents, and advances again. Decoding now produces"an".
The returned count matters. A general file or stream read is allowed to fill less than the whole buffer, and null marks the end of the file. This tiny regular-file example gets two bytes on both calls, but production code that requires an exact amount must keep reading until it has that amount or reaches null.
An FsFile is the right level when the program needs a position, partial reads, streaming, explicit flushing, or several operations through one open resource. For a small text file read once, readTextFile states the simpler intention.
using closes an open file at the end of its scope
file.stat(): Promise<Deno.FileInfo> reads metadata through an already-open handle rather than looking the entry up again by path. Choose it when the program already owns an FsFile. file.close(): void releases that handle immediately and returns no value; choose explicit close in a finally block when lexical disposal is unavailable.
using name = value is language syntax rather than a function. When value implements Symbol.dispose, JavaScript calls that disposal method as control leaves the scope. Deno.FsFile implements it by closing the handle, making using file = await Deno.open(...) the concise choice when ownership lasts for one lexical scope.
An open file consumes an operating-system resource. The using declaration in the previous step closed it at the end of the callback, but that cleanup was invisible. Give it a smaller block so the boundary can be observed:
Deno.test(
"using closes an open file at the end of its scope",
async () => {
await withScratch(async (root) => {
const note = join(root, "note.txt");
await Deno.writeTextFile(note, "hello");
const file = await Deno.open(note, { read: true });
{
using owned = file;
assert((await owned.stat()).isFile);
}
await assertRejects(() => file.stat(), Deno.errors.BadResource);
});
},
);
using closes an open file at the end of its scope ... ok (0ms)
Inside the braces, owned.stat() succeeds because the handle is open. using owned = file registers that value for disposal when execution leaves those braces. FsFile implements that disposal by closing itself. The later file.stat() reaches the same handle after closure and rejects with BadResource.
Calling file.close() in a finally block expresses the same responsibility manually. using ties the responsibility to the lexical scope, so a return or thrown error cannot skip it. The full syntax belongs to a future Explicit resource management reference; the fact needed here is concrete: an FsFile stays open until something closes it, and using closes it automatically at the end of its scope.
a directory inventory joins each name to its metadata
The last step composes the established operations into one practical result. readDir supplies names, join turns each name back into a path, and stat supplies the metadata for that path:
Deno.test(
"a directory inventory joins each name to its metadata",
async () => {
await withScratch(async (root) => {
await Deno.mkdir(join(root, "archive"));
await Deno.writeTextFile(join(root, "draft.txt"), "one");
await Deno.writeTextFile(join(root, "note.txt"), "hello");
const inventory: Array<{
name: string;
kind: "file" | "directory";
size: number | null;
}> = [];
for await (const entry of Deno.readDir(root)) {
let info: Deno.FileInfo;
try {
info = await Deno.stat(join(root, entry.name));
} catch (error) {
if (error instanceof Deno.errors.NotFound) continue;
throw error;
}
inventory.push({
name: entry.name,
kind: info.isDirectory ? "directory" : "file",
size: info.isFile ? info.size : null,
});
}
inventory.sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
);
assertEquals(inventory, [
{ name: "archive", kind: "directory", size: null },
{ name: "draft.txt", kind: "file", size: 3 },
{ name: "note.txt", kind: "file", size: 5 },
]);
});
},
);
a directory inventory joins each name to its metadata ... ok (1ms)
Walk one entry through the loop.
readDir(root)yields aDirEntrywhosenamemight be"draft.txt".join(root, entry.name)reconstructs the path to that particular child. The name alone would be relative to the working directory and would therefore ask a different question.statdescribes the child at the reconstructed path. If that child disappeared after it was listed,NotFoundskips the stale entry; every unrelated failure still escapes thecatch.- The pushed object keeps only the facts this inventory promises: name, file-or-directory kind, and a byte size for files.
- The explicit sort makes the result stable even though
readDirpromises no order.
The directory receives null for size because this inventory defines size as file-content bytes. It does not pretend the platform's directory metadata measures the bytes beneath that directory. The three files and directories are immediate children only, matching the one-level behavior already established for readDir.
This is the useful filesystem pattern in miniature: obtain a name from one operation, construct the full path deliberately, ask the next operation one precise question, and retain only the answer the application means to expose.
choose the narrowest operation
The best function is the one whose result and side effects already match the question. This map collects the use cases established above:
| Operation | Result | Choose it when |
|---|---|---|
Deno.cwd() | absolute path string | the caller's launch directory intentionally selects the location |
new URL(relative, import.meta.url) | module-anchored URL | an asset belongs beside a module regardless of launch directory |
fromFileUrl() | host path string | a file URL must cross into a path-only API |
basename() / dirname() | one structural portion | code needs to inspect a path without touching storage |
join() | normalized path string | known child segments should be attached to a known base |
resolve() / relative() | absolute path / relationship | code needs an absolute location or needs to compare two locations lexically |
Deno.readTextFile() / Deno.writeTextFile() | UTF-8 string / completion | a small text document should be transferred with encoding handled by Deno |
Deno.readFile() / Deno.writeFile() | bytes / completion | exact binary data matters more than text decoding |
Deno.makeTempDir() | unique directory path | one run needs isolated scratch storage that it will later remove |
Deno.mkdir() | completion | the application needs to create a known directory hierarchy |
Deno.readDir() | async sequence of child descriptions | code needs the immediate names and kinds inside one directory |
Deno.stat() / file.stat() | Deno.FileInfo | code needs metadata through a path or through an existing handle |
Deno.copyFile() | completion | one file should be duplicated at another path |
Deno.rename() | completion | one existing entry should move to another path |
Deno.remove() | completion | a known file, empty directory, or deliberately selected tree should be deleted |
Deno.open() | Deno.FsFile | position, partial transfer, repeated operations, streaming, or explicit flushing matters |
file.read() | byte count or null | the caller wants one bounded chunk from an open file |
file.sync() / file.close() | promise completion / void | pending file changes must be flushed or the open resource must be released |
“Completion” means an asynchronous operation's promise resolves without a useful return value; a synchronous void method simply returns. Neither means the operation is reversible, durable against power loss, or isolated from concurrent filesystem changes. Those are separate guarantees each use case must choose deliberately.
In practice
- Use a file URL based on
import.meta.urlfor module-owned assets, and@std/pathfor paths assembled at runtime. - Validate untrusted path segments, and grant only the directories and capabilities the program needs.
- Use the text helpers for small UTF-8 documents, byte helpers when bytes matter, and an
FsFileor stream when loading the whole file is the wrong shape. - Sort directory entries when order is visible, and expect a live directory to change between operations.
- Catch
NotFoundonly where absence has meaning, and usecreateNewwhen replacing would be a bug. - Close every handle, and clean up temporary trees on success and failure.
Related
- Streams and large files will cover bounded memory and backpressure.
- Symbolic links will cover the difference between a path and the entry eventually reached through it.
- Watching the file system will cover events whose grouping and order vary by operating system.
- Reliable file updates will cover durability, atomic replacement, and interruption recovery.