Docs · Standard library

`@io`

@io is the process-I/O package: the Writer/Reader interfaces, the three standard streams (stdin, stdout, stderr), and the program argument vector (args). The surface is identical on every target — on wasm the streams speak WASI, elsewhere libc — and the target split is resolved at compile time, so only the active implementation exists in your binary.

Everything here works in terms of [*con]u8 / [*mut]u8 byte views (pointer plus length; see guide 09 — Pointers and memory) and reports failure through the open error channel consumed with try and catch (see guide 07 — Optionals and errors).

The Writer and Reader interfaces

yaml
interface Writer { fn write(buf: [*con]u8) error!i32 }
interface Reader { fn read(buf: [*mut]u8) error!i32 }

Writer is "a place bytes go", Reader "a place bytes come from". Both speak the open error channel, so one signature covers every concrete sink or source — each concrete raises its own error declarations, which coerce into error at this boundary.

Code written against the interfaces takes any conforming value. Conformance is structural and scope-witnessed: a type satisfies Writer wherever a write with the right shape is in scope, and the concrete boxes at the call site with &:

vyi
import { Writer, stdout, stderr } from "@io"

fn emit(w: Writer, msg: [*con]u8) error!i32 {
    return w.write(msg)
}

fn main() !u8 {
    try emit(&stdout, "out\n")
    try emit(&stderr, "err\n")
    return Ok(0)
}

The standard streams below conform, and so does @mem's Buffer (both directions) — write into a Buffer to materialize bytes in memory instead of on a descriptor.

Streams

ExportTypeDescriptorDirection
stdinReadStream0read
stdoutWriteStream1write
stderrWriteStream2write

All three are exported con values, ready to use:

vyi
import { stdout, stderr } from "@io"

fn main() !u8 {
    try stdout.write("hello\n")
    try stderr.write("something went wrong\n")
    return Ok(0)
}

The stream types are exported so you can name them in signatures, but their descriptor field is private to the package — the three exported values are the only stream instances there are. You cannot conjure a stream over an arbitrary descriptor:

vyi-error
import { WriteStream } from "@io"

fn main() !u8 {
    con s: WriteStream = _{ fd: 1 }   // error: field "fd" is private to its defining file
    try s.write("x")
    return Ok(0)
}

write

yaml
fn (w: WriteStream) write(buf: [*con]u8) error!i32

Writes all of buf to the stream. Short writes are handled internally — write loops until every byte is delivered — so on success the returned count always equals buf.len. On failure it raises a WriteError member into the open error channel.

The result is an error union, not a bare count; consume it with try, catch, or a switch:

vyi-error
import { stdout } from "@io"

fn main() !u8 {
    con n: i32 = stdout.write("hi")   // error: cannot assign Result(error, i32) to i32
    return Ok(0)
}

read

yaml
fn (r: ReadStream) read(buf: [*mut]u8) error!i32

Fills buf from the stream and returns the number of bytes read. The contract:

  • read blocks until it can produce at least one byte.
  • A short read is normal — a successful read may return fewer than buf.len bytes. Call again for more.
  • Ok(0) means end of input, and only that. End of input is not an error; ReadError is reserved for genuine failures.

A single read:

vyi
import { stdin, stdout } from "@io"
import { systemAllocator } from "@mem"

fn main() !u8 {
    con buf = try systemAllocator.alloc(64, 1)
    con n = try stdin.read(buf)     // blocks; Ok(0) only at end of input
    if n > 0 {
        try stdout.write(buf[..n])  // echo back what arrived
    }
    return Ok(0)
}

Draining an entire input is the loop-until-Ok(0) pattern, reading into successive slices of one buffer:

vyi
import { stdin, stdout } from "@io"
import { systemAllocator, ArenaAllocator } from "@mem"

fn main() !u8 {
    mut arena = ArenaAllocator.new(systemAllocator)
    con alloc = arena.allocator()

    con buf = try alloc.alloc(512, 1)
    defer alloc.free(buf)

    mut filled = 0
    for {
        con n = try stdin.read(buf[filled..])   // Ok(0) = end of input
        if n == 0 { break }
        filled = filled + n
    }

    try stdout.write(buf[..filled])
    return Ok(0)
}

(Allocators are covered in the @mem reference.)

Errors

Two exported error declarations, one per direction, each member carrying its intrinsic message:

yaml
error ReadError {
    Interrupted "read interrupted";
    BadDescriptor "bad read descriptor";
    Broken "broken read stream";
}
error WriteError {
    Interrupted "write interrupted";
    BadDescriptor "bad write descriptor";
    NoSpace "no space on write stream";
    Broken "broken write stream";
}
MemberOnMeaning
Interruptedboththe operation was interrupted; retrying may succeed
BadDescriptorboththe underlying descriptor is invalid
NoSpacewritethe sink is full
Brokenboththe stream is closed or failing (a broken pipe is the classic case)

The stream methods raise these members into the open error channel their error!i32 signatures declare, so an unhandled failure that reaches a fn main() !u8 speaks for itself — a Broken read that escapes prints error: broken read stream.

To handle stream failures locally instead, catch the open error and narrow by member path:

vyi
import { stdout, stderr, WriteError } from "@io"

fn main() !u8 {
    stdout.write("best effort\n") catch (e: error) i32 => 0

    switch stderr.write("to stderr\n") {
        Ok(n: _):  { },                        // n bytes written
        Err(e: _): {
            switch e {
                WriteError.Interrupted:   { },
                WriteError.BadDescriptor: { },
                WriteError.NoSpace:       { },
                WriteError.Broken:        { },
                any:                      { },
            }
        },
    }
    return Ok(0)
}

Program arguments

yaml
fn args() ArgList

args() returns the process argument vector as an ArgList. Two properties define it:

  • User arguments only. The list holds the arguments passed to the program; the program name is not included. args().len() for a program run with no arguments is 0.
  • Heap-free. The arguments live in a static region the compiler reserves; args() allocates nothing and every at(i) result is a view into that region, valid for the life of the program.

ArgList

MemberSignatureDescription
lenfn (a: ArgList) len() i32number of arguments
atfn (a: ArgList) at(i: i32) [*con]u8argument i as a byte view, for 0 <= i < len()
bufpub buf: [*con]u8the backing buffer: all arguments, NUL-separated
countpub count: i32the same value len() returns

at walks the shared buffer to the i-th argument and returns it without its NUL terminator, so it costs O(buffer length) per call — fine for the handful of arguments programs actually take. The pub fields expose the raw layout for code that wants to scan the buffer itself.

vyi
import { args, stdout } from "@io"

fn main() !u8 {
    con av = args()
    mut i = 0
    for i < av.len() {
        try stdout.write(av.at(i))
        try stdout.write("\n")
        i = i + 1
    }
    return Ok(0)
}

Note on other packages

@io covers the standard streams and arguments; the rest of the standard library splits by concern:

  • @core — the auto-loaded prelude: Optional/Result, Vec, HashMap, String.
  • @mem — allocators (systemAllocator, ArenaAllocator, FixedBufferAllocator), byte operations, and Buffer (an in-memory Writer/Reader).
  • @fmt — formatting: the IntoString interface and print/println.
  • @errors — the rich Error type with construction-time stack capture (guide 07).
  • @trace — source-location access for the live call stack.
  • @testingdescribe/it/expect for vyi test (guide 16).
  • @crypto — secure randomness.
  • @c and @libc — the C type aliases and libc bindings (guide 15).
  • @wasm and @wasi-preview1 — the raw WebAssembly/WASI surfaces @io itself is built on; reach for them only when the portable surface here isn't enough.