fio logo

fio

Package Version License: MIT Hex 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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
fio.set_permissions(path, perms)Set permissions (type-safe)
fio.set_permissions_octal(path, mode)Set permissions (octal integer)

Directories

FunctionDescription
fio.create_directory(path)Create a directory
fio.create_directory_all(path)Create directory and parents
fio.list(path)List directory contents

Utility

FunctionDescription
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
}
FunctionDescription
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)

FunctionDescription
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'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

TargetRuntimeStatus
ErlangOTPFull support
JavaScriptNode.jsFull support
JavaScriptDenoFull support
JavaScriptBunFull 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 💜

Search Document