fio
“Complete”, safe, ergonomic file operations for all Gleam targets.
A single import for everything you need: read, write, copy, delete, symlinks, permissions, paths, atomic writes, file handles, and more. It comes with rich error types and cross-platform consistency.
All functionality is available via import fio (no need for submodule imports).
Features
- Unified API — One
import fiofor all file operations. No juggling multiple packages. - Cross-platform — Works identically on Erlang, Node.js, Deno, and Bun.
- Rich errors — POSIX-style error codes + semantic types like
NotUtf8(path). Pattern match precisely. - Atomic writes —
write_atomic/write_bits_atomicguarantee readers never see partial content. Temporary files are cleaned up even if the rename fails. - Random-access file handles —
seekandtelllet you jump to arbitrary byte offsets. - File handles —
fio/handleexposesopen,close,read_chunk,writefor large-file and streaming scenarios;withhelper prevents leaks. - Type-safe permissions —
FilePermissionswithSet(Permission), not magic integers. - Path operations —
join,split,expand,safe_relative, and more — built in. - Symlinks & hard links — Create, detect, read link targets.
- Symlink loop safety — Recursive operations track
(dev, inode)pairs; circular symlinks are listed but never descended into. On Windows, whereinodemay be zero, the full path string is used as a fallback key. - FFI safety — Erlang bindings map hash‑algorithm strings with a closed set, preventing atom table exhaustion.
- Touch — Create files or update timestamps, like Unix
touch. - Idempotent deletes —
delete_allsucceeds silently on non-existent paths.
Installation
gleam add fio
Quick Start
import fio
import fio/error
pub fn main() {
// Write and read
let assert Ok(_) = fio.write("hello.txt", "Ciao, mondo!")
let assert Ok(content) = fio.read("hello.txt")
// content == "Ciao, mondo!"
// Graceful error handling
case fio.read("missing.txt") {
Ok(text) -> use_text(text)
Error(error.Enoent) -> use_defaults()
Error(e) -> panic as { "Error: " <> error.describe(e) }
}
// Path safety (via the same `fio` facade)
let safe = fio.safe_relative("../../../etc/passwd")
// safe == Error(Nil) — blocked!
}
API Overview
Reading & Writing
| Function | Description |
|---|---|
fio.read(path) | Read file as UTF-8 string |
fio.read_bits(path) | Read file as raw bytes |
fio.write(path, content) | Write string (creates/overwrites) |
fio.write_bits(path, bytes) | Write bytes (creates/overwrites) |
fio.write_atomic(path, content) | Write string atomically (temp + rename) |
fio.write_bits_atomic(path, bytes) | Write bytes atomically (temp + rename) |
fio.append(path, content) | Append string |
fio.append_bits(path, bytes) | Append bytes |
File Operations
| Function | Description |
|---|---|
fio.copy(src, dest) | Copy a file |
fio.copy_directory(src, dest) | Copy a directory recursively |
fio.rename(src, dest) | Rename/move file or directory |
fio.delete(path) | Delete a file |
fio.delete_directory(path) | Delete an empty directory |
fio.delete_all(path) | Delete recursively (idempotent) |
fio.touch(path) | Create file or update modification time |
Note:
delete_alldoes not follow directory symlinks. A symlink itself is deleted but its target is left untouched. |fio.list_recursive(path)| List all files in a directory recursively |
Querying
| Function | Description |
|---|---|
fio.exists(path) | Check if path exists (files, directories, symlinks) |
fio.is_file(path) | Check if path is a regular file |
fio.is_directory(path) | Check if path is a directory |
fio.is_symlink(path) | Check if path is a symbolic link |
fio.file_info(path) | Get file metadata (follows symlinks) |
fio.link_info(path) | Get metadata without following symlinks |
Symlinks & Links
| Function | Description |
|---|---|
fio.create_symlink(target:, link:) | Create a symbolic link |
fio.create_hard_link(target:, link:) | Create a hard link |
fio.read_link(path) | Read symlink target path |
Permissions
| Function | Description |
|---|---|
fio.set_permissions(path, perms) | Set permissions (type-safe) |
fio.set_permissions_octal(path, mode) | Set permissions (octal integer) |
Directories
| Function | Description |
|---|---|
fio.create_directory(path) | Create a directory |
fio.create_directory_all(path) | Create directory and parents |
fio.list(path) | List directory contents |
Utility
| Function | Description |
|---|---|
fio.current_directory() | Get working directory |
fio.tmp_dir() | Get system temp directory |
Cross-platform behavior notes
Some behavior differs between BEAM (Erlang/OTP) and JavaScript runtimes (Node/Deno/Bun). The library aims to keep the API consistent, but underlying platform differences can affect:
-
Synchronous I/O: The JS implementation uses synchronous filesystem calls (
fs.readFileSync,fs.writeFileSync, etc.). This is appropriate for many Gleam apps, but it blocks the event loop. If you target Deno/Bun, the runtime may still work (they provide Node compatibility layers) but the operations remain blocking. -
Permissions: POSIX-style
chmod/statbehavior is only meaningful on Unix-like platforms. On Windows, permissions queries/changes may be no-ops or behave differently, andset_permissionsmay returnEperm/Enotsup. -
Symlink creation: Some platforms (notably Windows) require elevated privileges to create symlinks; when symlink creation fails, the library surfaces the OS error.
-
Path normalization: The
fio/pathmodule delegates tofilepath(BEAM) or Node’spath(JS). Windows paths may use backslashes (\\) and drive letters;safe_relativenormalizes backslashes to forward slashes to ensure consistent behavior.For example, on Node.js (macOS host) the output of
path.join("C:\\foo", "bar")isC:\foo/bar, whilepath.win32.joinyieldsC:\foo\\bar. On BEAM,fio/path.joincurrently yieldsC:\foo/bar(mixing separators) andpath.split("C:\\foo\\bar")returns a single segment"C:\\foo\\bar".You can inspect runtime behavior across targets using:
node dev/path_behavior.js # (or deno run dev/path_behavior.js, bun dev/path_behavior.js if available) -
File handles: On Node.js, append mode is enforced by the OS only when write calls use a
nullposition;fio/handletracks position and forcesnullwhen in append mode to preserve POSIX semantics.
Tip: If you rely on strict POSIX behavior (permissions, symlink semantics, dev/inode metadata), prefer running on Erlang/OTP where those semantics are stable.
File Handles (fio/handle)
For large files or streaming scenarios where loading the entire content into
memory is not acceptable, use the fio/handle module:
import fio/handle
import gleam/result
// Read a large log file chunk by chunk (64 KiB at a time)
pub fn count_bytes(path: String) -> Result(Int, error.FioError) {
use h <- result.try(handle.open(path, handle.ReadOnly))
let assert Ok(bits) = handle.read_all_bits(h)
let _ = handle.close(h)
Ok(bit_array.byte_size(bits))
}
// Write to a file with explicit lifecycle control
pub fn write_lines(path: String, lines: List(String)) -> Result(Nil, error.FioError) {
use h <- result.try(handle.open(path, handle.WriteOnly))
let result = list.try_each(lines, fn(line) { handle.write(h, line <> "\n") })
let _ = handle.close(h)
result
}
| Function | Description |
|---|---|
handle.open(path, mode) | Open a file (ReadOnly, WriteOnly, AppendOnly) |
handle.close(handle) | Close the handle, release the OS file descriptor |
handle.read_chunk(handle, size) | Read up to size bytes; Ok(None) at EOF |
handle.read_all_bits(handle) | Read all remaining bytes as BitArray |
handle.read_all(handle) | Read all remaining content as UTF-8 String |
handle.write(handle, content) | Write a UTF-8 string |
handle.write_bits(handle, bytes) | Write raw bytes |
Note:
FileHandleis intentionally opaque. Always callclosewhen done — the OS file descriptor is not automatically released.
Path Operations (fio/path)
| Function | Description |
|---|---|
path.join(a, b) | Join two path segments |
path.join_all(segments) | Join a list of segments |
path.split(path) | Split path into segments |
path.base_name(path) | Get filename portion |
path.directory_name(path) | Get directory portion |
path.extension(path) | Get file extension |
path.stem(path) | Get filename without extension |
path.with_extension(path, ext) | Change extension |
path.strip_extension(path) | Remove extension |
path.is_absolute(path) | Check if path is absolute |
path.expand(path) | Normalize . and .. segments |
path.safe_relative(path) | Validate path doesn’t escape via .. |
Atomic Writes
write_atomic and write_bits_atomic implement the write-to-temp-then-rename
pattern, which is the standard POSIX-safe way to update files:
import fio
import fio/error
pub fn save_config(path: String, json: String) -> Result(Nil, error.FioError) {
// Readers always see either the old file or the complete new one.
// A crash between the write and rename leaves a harmless .tmp sibling.
fio.write_atomic(path, json)
}
Use write_atomic whenever:
- The file is read by other processes while it may be updated.
- A crash or power loss must not leave a partially-written file.
- The file is a config, lock, or state file.
Use plain write for scratch files, logs, or temporary output where
partial writes are acceptable.
Error Handling
fio uses FioError: 39 POSIX-style error constructors plus 7 semantic variants; each error has a human-readable description available via error.describe:
import fio
import fio/error.{type FioError, Enoent, Eacces, NotUtf8}
case fio.read("data.bin") {
Ok(text) -> use(text)
Error(Enoent) -> io.println("Not found")
Error(Eacces) -> io.println("Permission denied")
Error(NotUtf8(path)) -> {
// File exists but isn't valid UTF-8 — use read_bits instead
let assert Ok(bytes) = fio.read_bits(path)
use_bytes(bytes)
}
Error(e) -> io.println(error.describe(e))
}
Every error has a human-readable description via error.describe.
Type-Safe Permissions
import fio
import fio/types.{FilePermissions, Read, Write, Execute}
import gleam/set
let perms = FilePermissions(
user: set.from_list([Read, Write, Execute]),
group: set.from_list([Read, Execute]),
other: set.from_list([Read]),
)
fio.set_permissions("script.sh", perms)
// -> Ok(Nil)
Platform Support
Development
Run the complete test suite locally across targets with the helper script:
./bin/test # Erlang + JavaScript
./bin/test erlang # Erlang only
./bin/test javascript # Node.js only
This mirrors the CI matrix without needing to publish the package.
Platform Support
| Target | Runtime | Status |
|---|---|---|
| Erlang | OTP | Full support |
| JavaScript | Node.js | Full support |
| JavaScript | Deno | Full support |
| JavaScript | Bun | Full support |
Platform Notes & Limitations
Some behaviours vary by OS or filesystem. The library strives for consistency but there are edge cases you should be aware of:
-
Windows differences – permission‑setting functions are no‑ops and
%ooctal permissions are ignored by the OS. Atomic rename may fail if the destination already exists (a Windows API restriction); a failure returnsAtomicFailed("rename", reason)and the temp file is removed. Recursive traversal uses inode numbers when available; on Windowsinois typically zero, so the code falls back to a visited path string. Tests for Windows behaviour run conditionally and the README makes these caveats explicit. -
Atomic write caveats –
write_atomic/write_bits_atomicimplement write‑to‑temp‑then‑rename. This guarantees readers never see a partial file on POSIX filesystems, but does not protect you from:- crashes that occur between the temp write and the rename (a
.tmpsibling may be left behind), - non‑POSIX mounts (SMB, NFS with strange semantics) where rename may not be atomic. Always clean up temp files periodically if you run on untrusted filesystems.
- crashes that occur between the temp write and the rename (a
-
Recursive read/write –
handle.read_all_bitsnow uses an iterative loop to avoid stack overflow on extremely large files. The previous recursive implementation worked but could blow the call stack for multi‑gigabyte reads. -
Path utilities –
path.join_all([])returns"."(previously"") which better matches user expectations.path.safe_relativedetects and blocks Windows drive letters as well as Unix absolute paths; it still simply normalises..segments, so be cautious when operating on network shares. -
Error mapping – the FFI bridge maps all known POSIX errors; if a new platform error is received it becomes
Unknown(inner, _). Add new cases tofio_ffi_bridgewhen extending the error set. -
No async/watch support – all APIs are synchronous. Reading very large files will block the BEAM scheduler or the JavaScript event loop; use
fio/handlewith small chunks or move heavy I/O off the main thread.
These notes are intentionally broad; see the module docs for more details on individual functions.
Cross-Platform Notes
NotUtf8detection is consistent across Erlang and JavaScript.delete_allis idempotent: succeeds silently if the path doesn’t exist.- Symlink functions may require elevated privileges on Windows.
- Permissions functions (
set_permissions,set_permissions_octal) have no effect on Windows.
License
MIT
Made with Gleam 💜