I/O, Formatting, and OS
This proposal has been accepted and implemented.
You can use this feature in the latest version of Zirric.
Introduction
This proposal introduces three new standard library modules — io, fmt, and os — that provide Zirric programs with the ability to interact with the outside world. It also proposes adding a Bytes type to the prelude and moving the @Printable attribute into the prelude alongside @Numeric.
Together, these form the minimal foundation needed for real-world programs: reading input, writing output, formatting text, and accessing the operating system environment.
Motivation
Zirric currently has no mechanism for interacting with the outside world. A program cannot print output, read input, access environment variables, or terminate with an exit code. This makes it impossible to write useful command-line tools or interactive programs.
The proposed module split follows a layered design where abstract interfaces (io) are separated from concrete OS bindings (os) and formatting utilities (fmt). This separation enables testability by construction — since io.Reader and io.Writer are data types, any function that accepts them can be tested with mock implementations without any special tooling or dependency injection framework.
Proposed Solution
The proposal introduces four additions to the standard library:
Bytesand@Printablein the prelude — a fundamental binary data type and string conversion attributeiomodule — abstractReaderandWriterdata types for byte-level I/Ofmtmodule — formatting and printing functions built onioosmodule — extern functions that provide access to the real operating system
Module Dependency Graph
prelude (Bytes, @Printable)
↑
io (Reader, Writer, CountResult)
↑ ↑
fmt os
iodepends only on the prelude.osdepends onio(returnsReader/Writer).fmtdepends onio(acceptsWriter) andos(usesstdoutfor convenience functions).
There are no circular dependencies.
Detailed Design
Prelude: Bytes and @Printable
Two declarations are added to the prelude.
Bytes is the fundamental binary data type, the byte-level counterpart to String:
extern type Bytes {
length: Int
}
Any module that performs I/O needs Bytes. It is placed in the prelude because it is as foundational as String or Int.
Conversion between String and Bytes requires an explicit function. Until extern type constructors (ZE-015) are available, the io module provides a bridge function.
@Printable marks types that have a string representation:
attr Printable {
toString(self: @Printable) -> String
}
This lives in the prelude alongside @Numeric because converting values to strings is equally fundamental. Built-in types are annotated at declaration:
@Printable(fn(self) { return self })
extern type String { length: Int }
@Printable(fn(self) { return _intToString(self) })
extern type Int {}
@Printable(fn(self) { return _floatToString(self) })
extern type Float {}
@Printable(fn(self) { if self { return "true" } else { return "false" } })
extern type Bool { toggle() -> Bool }
The io Module
The io module defines abstract byte-level I/O interfaces. It contains no extern functions that interact with the OS — all types are data declarations that can be freely constructed by anyone.
mod io
import future.prelude { Err, AnyResult }
// The result of a read or write operation.
// On success, contains the number of bytes transferred.
// On failure, contains an Err.
@AnyResult()
union CountResult {
Int
Err
}
// An abstract byte reader.
// Reads up to buf.length bytes into buf.
// Returns the number of bytes read or an error.
data Reader {
read(buf: Bytes) -> CountResult
}
// An abstract byte writer.
// Writes the contents of buf.
// Returns the number of bytes written or an error.
data Writer {
write(buf: Bytes) -> CountResult
}
// Converts a String to its Bytes representation.
// Interim solution until Bytes supports direct construction (ZE-015).
extern fn bytesFromString(str: String) -> Bytes
Because Reader and Writer are data types, they are mockable by construction:
import io { Writer }
const written = []
const mockWriter = Writer(fn(buf) {
written.push(buf)
return buf.length
})
No test doubles library or dependency injection is needed. Any function that accepts a Writer or Reader parameter is inherently testable.
The fmt Module
The fmt module provides text formatting and printing utilities. It builds on io for byte-level output and uses @Printable from the prelude.
mod fmt
import io { Writer, CountResult, bytesFromString }
import os { stdout }
// Formats a value as a String without performing any I/O.
fn sprint(value: @Printable) -> String {
return Printable(value).toString(value)
}
// Writes the string representation of a value to a Writer.
fn fprint(value: @Printable, writer: Writer) -> CountResult {
return writer.write(bytesFromString(sprint(value)))
}
// Writes the string representation of a value followed by a newline to a Writer.
fn fprintln(value: @Printable, writer: Writer) -> CountResult {
return fprint(sprint(value) + "\n", writer)
}
// Writes the string representation of a value followed by a newline to standard output.
fn println(value: @Printable) -> CountResult {
return fprintln(value, stdout())
}
The os Module
The os module provides access to the real operating system. It is the only module in this proposal that contains extern function declarations with OS side effects.
mod os
import io { Reader, Writer }
import future.prelude { Option }
// Returns a Reader for the standard input stream.
extern fn stdin() -> Reader
// Returns a Writer for the standard output stream.
extern fn stdout() -> Writer
// Returns a Writer for the standard error stream.
extern fn stderr() -> Writer
// Terminates the program with the given exit code.
extern fn exit(code: Int) -> Void
// Returns the value of the environment variable with the given key,
// or None if the variable is not set.
extern fn env(key: String) -> Option
// Returns all environment variables as a dictionary.
extern fn envs() -> Dict
// Returns the command-line arguments passed to the program.
extern fn args() -> [String]
Putting It Together
A complete “Hello, World!” program:
import fmt { println }
println("Hello, World!")
A program that reads environment configuration and writes to stderr:
import fmt { fprintln }
import os { env, stderr }
const name = env("USER") ?? "stranger"
fprintln("Hello, " + name + "!", stderr())
A testable function that accepts a Writer:
import fmt { fprint }
import io { Writer }
fn greet(name: String, out: Writer) {
fprint("Hello, " + name + "!", out)
}
// In production: greet("World", os.stdout())
// In tests: greet("World", mockWriter)
Changes to the Standard Library
Prelude Additions
| Declaration | Kind | Description |
|---|---|---|
Bytes |
extern type |
Binary data type with length: Int |
Printable |
attr |
String representation attribute with toString |
New Modules
| Module | Exports |
|---|---|
io |
Reader, Writer, CountResult, bytesFromString |
fmt |
sprint, fprint, fprintln, println |
os |
stdin, stdout, stderr, exit, env, envs, args |
New Types
| Type | Module | Kind | Description |
|---|---|---|---|
Bytes |
prelude | extern type |
Binary data buffer |
CountResult |
io |
union |
Int | Err — result of read/write operations |
Reader |
io |
data |
Abstract byte reader |
Writer |
io |
data |
Abstract byte writer |
New Functions
| Function | Module | Signature | Description |
|---|---|---|---|
bytesFromString |
io |
(String) -> Bytes |
String-to-Bytes conversion (interim) |
sprint |
fmt |
(@Printable) -> String |
Format value as String |
fprint |
fmt |
(@Printable, Writer) -> CountResult |
Write formatted value to Writer |
fprintln |
fmt |
(@Printable, Writer) -> CountResult |
Write formatted value + newline to Writer |
println |
fmt |
(@Printable) -> CountResult |
Write formatted value + newline to stdout |
stdin |
os |
() -> Reader |
Standard input stream |
stdout |
os |
() -> Writer |
Standard output stream |
stderr |
os |
() -> Writer |
Standard error stream |
exit |
os |
(Int) -> Void |
Terminate with exit code |
env |
os |
(String) -> Option |
Get environment variable |
envs |
os |
() -> Dict |
Get all environment variables |
args |
os |
() -> [String] |
Get command-line arguments |
Breaking Changes
None. This proposal only adds new modules and types.
Dependencies on Other Proposals
| Proposal | Dependency |
|---|---|
| ZE-008 Error Handling | CountResult uses Err and @AnyResult() for result sugar |
| ZE-009 Option Values | os.env() returns Option for missing variables |
| ZE-015 Extern Type Constructors | Once implemented, bytesFromString can be replaced with a Bytes constructor |
Alternatives Considered
Single Module
All I/O, formatting, and OS functionality could live in a single os or io module. This was rejected because it conflates abstract interfaces with concrete OS bindings, making it harder to write testable code and harder to reason about which functions have side effects.
Capability Bundle (data OS)
An alternative design bundles all OS capabilities into a single data type:
data OS {
stdin: Reader
stdout: Writer
stderr: Writer
exit(code: Int)
envs() -> Dict
args() -> [String]
}
const host = OS(...)
This enables single-point mockability for testing. However, it was deferred because:
- Too coarse — functions that only need
stdoutwould receiveexit,envs, andargstoo, violating the principle of least authority. - Premature — it cements a dependency injection pattern before real usage reveals what groupings are natural.
- Unnecessary — since
ReaderandWriterare alreadydatatypes, individual function parameters are already testable. - Additive — a capability bundle can be introduced later as a non-breaking addition once
fsand other modules establish clearer grouping needs.
@Printable in fmt Instead of Prelude
@Printable could live in the fmt module rather than the prelude. This would give fmt ownership of its primary attribute, but creates a circular dependency: built-in types (String, Int, etc.) need @Printable annotations, and those types are declared in the prelude. Without retroactive attribute application, @Printable must be in scope when types are declared. Placing it in the prelude — alongside the analogous @Numeric attribute — is the pragmatic choice.
Separate proc Module for Process Operations
Process-related functions (exit, args, envs, env) could be split into a proc module separate from os. This was rejected because the total API surface is small (~7 functions) and a split would add a module without meaningful conceptual benefit. If the OS surface grows significantly in the future (e.g., signals, child processes), splitting can be reconsidered.
Acknowledgements
The module split follows patterns established by Go’s standard library (io, fmt, os) adapted to Zirric’s data-type-based interface model. The Reader/Writer abstraction takes inspiration from Go’s io.Reader and io.Writer interfaces, with the key difference that Zirric’s data types are inherently mockable without requiring interface satisfaction.