fio logo

fio

Package VersionLicense: MITHex Docs

"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

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_all does 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:

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: FileHandle is intentionally opaque. Always call close when 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:

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&#39;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:

These notes are intentionally broad; see the module docs for more details on individual functions.

Cross-Platform Notes

License

MIT


Made with Gleam 💜