The Oneway Programming Language

Oneway is a small, opinionated language that transpiles to Rust. It inherits Rust's ownership model and zero-cost abstractions while presenting a much smaller surface area to the programmer.

The guiding rule: wherever ordering is discretionary, the compiler enforces alphabetical order. Components of product types, variants of unions, method declarations, match arms, imports — all alphabetical. Reordering is never a meaningful change.

What It Looks Like

Bool = False | True

main = (Stdout) -> Noop {
    List(1, 2, 3)
        .map((Int) -> Int { Int.mul(2) })
        .length()
        .print(Stdout)
}

A few things to notice:

  • There is no let, no local variables, no if/else, no comments.
  • Every function is implemented on a type: Type.name = (params) -> Ret { ... }.
  • The exception is main, the program's entry point.
  • Branching is match on a union.
  • Side effects are passed in as capabilities (Stdout, Filesystem, …).
  • Imports are file-based: use Foo imports the type declared in foo.ow.

Status

Oneway is an experimental design exploration. The compiler exists, examples run, and the design is stable enough to write about — but every detail is subject to change.

The reference implementation lives in the same repository as this book. The authoritative design spec is DESIGN.md.

How to Read This Book

  • Getting Started — install the toolchain and run your first program.
  • A Tour of Oneway — every feature, one short chapter each.
  • Reference — sort-order rules, operator table, Rust comparison.

The chapters are short on purpose. Read straight through, or skip to whatever you need.

Installation

Oneway is distributed as source. You build the compiler with cargo, then use it to compile .ow programs.

Prerequisites

  • Rust (stable) — install via rustup.
  • just — a command runner. Install with cargo install just or your package manager.
  • A working C linker (clang or gcc) — already present on most systems.

Building the Compiler

Clone the repository and build:

git clone https://github.com/Almaju/oneway
cd oneway
just build

This produces a debug binary at target/debug/oneway. The justfile at the project root wraps the compiler in convenient recipes, so you rarely call the binary directly.

Verifying the Install

Run the bundled hello-world example:

just run examples/hello.ow

You should see:

hello

Repository Layout

PathWhat it is
src/The compiler (lexer, parser, checker, codegen).
examples/Sample .ow programs.
editors/Tree-sitter grammar and Zed extension.
DESIGN.mdThe full language specification.

For editor support, see editors/README.md in the repository.

Hello, World

Create a file named hello.ow:

main = (Stdout) -> Noop {
    "hello".print(Stdout)
}

Run it:

just run hello.ow

You should see:

hello

That's the whole program. Let's walk through it.

Line by Line

main = (Stdout) -> Noop {

main is the program's entry point. Unlike every other function in Oneway, main is not implemented on a type — it's a top-level binding.

The signature (Stdout) -> Noop says: this function takes one parameter whose type is Stdout, and returns a value of type Noop.

  • Stdout is a capability. Real-world capabilities only exist in main, which receives them and threads them down to anything that needs to perform a side effect.
  • Noop is a singleton type — a type with exactly one value, named after itself. Returning Noop is the language's way of saying "this function produces nothing useful".
    "hello".print(Stdout)
}

"hello" is sugar for String("hello"). The body of a function is a sequence of expressions separated by newlines; the last one is the return value.

"hello".print(Stdout) is a method call. The method is defined on String:

String.print = (Stdout) -> Noop {
    ...
}

print needs a Stdout capability because writing to standard output is a side effect. A function that does not receive Stdout cannot print, and the type signature is honest about that.

Try Breaking Things

Some small experiments to build intuition:

  • Remove Stdout from main's parameters. The compiler will complain when print is called without it.
  • Add a comment (// hi). The lexer rejects this — comments are not allowed.
  • Return something other than Noop. The body's last expression must match the declared return type.

Building and Running

The compiler is invoked through just recipes. The ones you'll actually use, day to day:

Run a Program

just run path/to/file.ow

Compiles the file to a native binary placed next to the source, runs it, prints the output, and removes the binary afterward.

Run an Example by Name

just example hello          # runs examples/hello.ow
just example multifile      # runs examples/multifile/main.ow

Run Every Example

just examples

Compiles and runs every file in examples/, reporting which passed, failed, or were skipped (skipped means the example does not yet compile).

Inspect the Generated Rust

just emit path/to/file.ow

Prints the Rust source that the transpiler produces. This is the best way to build a mental model of how Oneway constructs map to Rust.

Show Tokens or AST

just tokens path/to/file.ow
just ast    path/to/file.ow

Both are diagnostic — useful when you want to know exactly how the lexer/parser sees your code.

Check Sort Order

just check path/to/file.ow

Validates only the sort-order rules (alphabetical ordering of declarations, match arms, imports, etc.) without doing the rest of compilation.

Tests and Linting

just test            # cargo test the compiler
just fmt             # cargo fmt the compiler source
just clippy          # cargo clippy the compiler source
just clean           # remove build artifacts and compiled examples

Workflow

There is no oneway new or project scaffolder. Single-file programs are first class — drop a .ow file anywhere and just run it. For multi-file projects, see Modules.

Philosophy

There is one way to do everything.

Most modern languages give you ten ways to do the same thing and then ask you to pick. Oneway picks for you. If there's a best practice, it's the only practice — and the compiler enforces it.

Alphabetical Order, Everywhere

The single most pervasive rule. Whenever ordering is discretionary, declarations must be in alphabetical order. This applies to:

  • Components of a product type: User = Birthday & Username
  • Variants of a union type: Bool = False | True
  • Multiple methods on a type (declared top-to-bottom alphabetically)
  • Arms of a match (in the order of the union's variants)
  • Trait composition: Show = Debug & PrintString
  • Error unions inside Result: Result<T, IoError | NotFound>
  • Multiple use statements at the top of a file

Reordering is never a meaningful change. Diffs that only reshuffle a list do not exist. Two programmers writing the same code produce the same bytes.

Types Are the Documentation

Oneway has no local variables, no let, and no parameter names. The shape of a function is described entirely by its types.

User.compare = (OtherUser) -> Ord {
    User.Birthday.compare(OtherUser.Birthday)
}

The receiver is referred to as User (its type). The parameter is referred to as OtherUser (its type). If you need to disambiguate two parameters of the same type, you define a newtype — that newtype becomes the documentation:

User      = Birthday & Username
OtherUser = User

The principle: names lie, types don't. Forcing every value through a named type makes the data flow explicit and the documentation structural.

Effects Are Honest

A function's signature should not lie about what it does. print writes to the screen, so it requires a Stdout capability — passed as an ordinary argument from main:

String.print = (Stdout) -> Noop {
    ...
}

A function that does not receive a capability cannot perform the corresponding effect. No monads, no effect system — just types.

No Comments

There are no comments. Code must speak for itself through types and naming. If you find yourself wanting to write a comment, the right answer is usually to introduce a newtype or rename a method.

Batteries-Included

Oneway ships opinionated binding packages for the major application domains — HttpServer, HttpClient, Filesystem, Database, Json, and more — each wrapping a chosen Rust crate. The user gets a single curated import per domain (use HttpServer, use Filesystem, …) without having to evaluate the Rust crate ecosystem. The community is free to publish additional bindings, but the headline batteries ship with the language.

Under the hood, every binding is implemented in ordinary Oneway via extern Rust declarations over its underlying crate. There is no privileged path — anyone can write the same bindings; Oneway just ships them so users don't have to.

Types

Every type in Oneway is built by composing two operators — | for "or", & for "and" — over a small core of primitives.

Naming

  • Types and traits: PascalCase
  • Methods: camelCase

The case difference distinguishes a method from a trait implementation declared on the same type:

Type.print  // method
Type.Print  // implementation of the `Print` trait

Unions (|)

A union expresses "this or that":

Bit  = Off | On
Bool = False | True
Ord  = Equal | Greater | Less

Variants must be listed in alphabetical order. There is no separate enum keyword.

Products (&)

A product expresses "this and that". A value of the resulting type has all of its components:

User = Birthday & Username

Components must be in alphabetical order. There is no separate struct keyword.

Field Access

A product's components are addressed by their type name:

user.Birthday
user.Username

For repeated components or anonymous sequences, use 1-based positional indices:

Byte = Bit[8]

byte.1   // first Bit
byte.2   // second Bit

Newtypes

Aliasing a type creates a distinct new type that wraps the original:

Birthday = String
Username = String

Birthday and Username cannot be used interchangeably. They share storage, but they are different types — which is exactly the point. See Philosophy on why types are the documentation.

Fixed and Unbounded Repetition

For a fixed count of the same type, use Type[N]:

Byte = Bit[8]

For unbounded sequences, use ...Type:

Bytes = ...Byte

Higher-level types like Int, Float, and String are defined from Byte / Bytes.

Generics

Type parameters use angle brackets:

List<T>
Option<T>
Result<T, E>
Map<String, Int>

Constraints on type parameters use :, naming a trait the parameter must implement:

List.print = <T: Print>() -> Noop {
    ...
}

Singleton Types

A type with no underlying composition has exactly one value, referenced by writing the type name itself:

main = () -> Noop {
    Noop
}

Noop in return position is the type; Noop in expression position is its sole value. No constructor call is needed (and would not work — there is no data to pass).

Recursive Types

Recursive type definitions are allowed and boxed automatically:

Branch = Left & Right & Value
Left   = Tree
Right  = Tree
Tree   = Branch | Leaf
Value  = Int

There is no user-visible Box<T>. The transpiler chooses an indirection scheme; it is never spelled out in source.

Type Inference

There is none. Every type must be explicitly written. If a function declares it returns Result<T, Err> but no Err ever flows through, that is a compile-time error — declared types must match inferred shape exactly.

Literals

Oneway is values-only. There is no new, no implicit nullability, no keywords like true or false. Every value is built by calling its type's constructor.

Constructors

Every type T has a constructor T(_). The argument is a value matching the type's underlying definition:

KindConstructorArgument is…
PrimitiveInt(123), Float(1.0), String("hi")a literal of the corresponding lexical kind
HexHex(0xFF0000)a hex literal
Product A & BT(A(...) & B(...))a value-level product joined with &
Union A | BT(A(...)) or T(B(...))a value of any variant
NewtypeT(inner)a value of the aliased type

Literal Sugar

A handful of literals desugar to their constructors:

LiteralDesugars to
123Int(123)
1.0Float(1.0)
"abc"String("abc")
0xFF0000Hex(0xFF0000)

Numeric literals exist to avoid boilerplate in arithmetic-heavy code. String literals exist to avoid the parsing ambiguity of bare String(...) with spaces and punctuation.

Singleton Values

A singleton type — one with no underlying composition — has one value, referenced by writing the type name:

Noop      // the sole value of type Noop
On        // the sole value of type On

No Empty Constructors

String(), Int(), User() — calling any constructor with zero arguments is a compile-time error. If a value can legitimately be "missing", that absence belongs in the type as Option<T>. Otherwise the type requires its data.

For factory-style construction (an empty list, etc.), use an explicit method like List.empty or String.empty.

Constructing a Product

The argument to a product's constructor is its components joined with value-level &:

user = User(Birthday(...) & Username("ahanot"))
red  = Hex(0xFF0000)

& is overloaded across the two levels: at the type level it forms a product type, at the value level it forms a product value. The two never appear in the same context.

Validated Constructors (Type.Self)

By default, a type's constructor is total: T(inner) always succeeds and returns T. For types whose construction can fail — a Url parsed from a String, an Email from a String, etc. — the fallibility belongs in the type system as Result<T, E>. Same principle the language already applies to "missing": Option<T>.

A type opts into this by declaring a method named Self:

Url = String

extern Rust("oneway_url_parse")
Url.Self = (String) -> Result<Url, InvalidUrl>

Self is the alias for the receiver type's name; Type.Self reads as "the constructor that produces a Self."

When a type declares Type.Self, that is the constructor — the implicit total constructor is replaced. The signature is unconstrained: total ((String) -> Url), fallible (Result<Url, InvalidUrl>), or optional (Option<Url>). Call sites still use the ordinary constructor syntax Url("https://example.com"), but the expression's type is now whatever Url.Self returns, so a fallible constructor forces ? (or match) at the call site:

HttpClient.get(Url("https://example.com")?)?.print(Stdout)

External callers cannot bypass the constructor. The raw inner representation is only accessible inside the same file as the type.

Functions

Every function is implemented on a type. The general form is:

Type.functionName = (params) -> ReturnType {
    body
}

The only exception is main, the program's entry point.

A First Method

Greeting = String

Greeting.shout = () -> String {
    "HELLO"
}

main = (Stdout) -> Noop {
    Greeting("howdy").shout().print(Stdout)
}

Greeting.shout is a method on Greeting. It is called with dot syntax: Greeting("howdy").shout().

Method Bodies

A body is a newline-separated sequence of expressions. The last expression is the return value. There are no semicolons.

  • match is an expression — it can be the final line of a body or appear as a sub-expression.
  • while and for are expressions of type Noop.
  • Non-final lines whose results are discarded are valid (they exist for side effects or ? propagation).
File.readConfig = (Path) -> Result<Config, IoError | ParseError> {
    File.read(Path)?
        .parse()?
        .validate()
}

There are no local variables. The only way to thread a value through multiple operations is method chaining. That is the intended style.

Referring to the Receiver

Inside a method body, the receiver value is referenced by the receiver type's name:

String.print = (Stdout) -> Noop {
    Stdout.write(String)    // `String` here is the receiver value
}

The Self keyword is an alias, available everywhere. It is required only when the receiver's type name collides with a parameter of the same type:

Int.add = (Int) -> Int {
    ...   // ambiguous: which `Int`?
}

Resolve it either by using Self:

Int.add = (Int) -> Int {
    Self.plus(Int)
}

…or by introducing a newtype for the parameter:

OtherInt = Int

Int.add = (OtherInt) -> Int {
    Int.plus(OtherInt)
}

Self is the lighter-weight choice. The alias is right when the distinction is meaningful enough to warrant a name.

Declaration Order

Multiple methods on the same type must be declared in alphabetical order. This is a compile-time requirement, not a convention:

User.add    = (...) -> ...
User.export = (...) -> ...
User.remove = (...) -> ...

Visibility

Everything is public by default. Prefix a method with * to make it private to its declaring file:

Type.*helper = () -> Noop {
    ...
}

Optional Parameters

There is no special syntax. Use Option<T>:

String.print = (Option<Color>) -> Noop {
    ...
}

This allows both forms at the call site:

"hello".print()
"hello".print(Red)

First-Class Functions

Methods are first-class values. Refer to one by its qualified name Type.method and pass it where a matching trait signature is expected:

Numbers = ...Int

Numbers.doubleAll = () -> Numbers {
    Numbers.map(Int.double)
}

Lambdas

For one-off operations, write a lambda literal with its full signature. There is no signature inference:

Numbers.tripleAll = () -> Numbers {
    Numbers.map((Int) -> Int { Int.mul(Int(3)) })
}

Lambda syntax mirrors method declaration syntax: (params) -> ReturnType { body }. The only difference is the absence of a Type.name = prefix.

Generic Methods

A method can be parameterized by a type. Declare type parameters with <...> before the parameter list, optionally with a trait constraint:

List.print = <T: Print>() -> Noop {
    ...
}

When calling a generic method whose type parameter can't be inferred from context, pin it with ::<...> (turbofish) after the method name:

Json.parse::<List<Int>>("[1, 2, 3]")?

Turbofish is only required when the surrounding type context doesn't already determine the parameter. A function with an explicit Result<List<Int>, _> return type lets the compiler infer from the return position without an annotation.

The main Function

main is the single exception to "every function is on a type". It is a top-level free function and the program's entry point. It typically takes the capabilities the program needs:

main = (Stdout) -> Noop {
    "hello".print(Stdout)
}

See Capabilities for how this connects to side effects.

Match

There is no if/else. All branching is match on a union.

Basic Form

Bool = False | True

main = (Stdout) -> Noop {
    match True {
        False => "no".print(Stdout),
        True  => "yes".print(Stdout),
    }
}

match is an expression. It can be the final line of a function body or appear as a sub-expression.

Arm Order

Match arms follow the union's variant order — which is itself alphabetical. There is no _ wildcard in the spec; every variant must be spelled out:

Ord = Equal | Greater | Less

Int.classify = () -> Sign {
    match Int.compare(Int(0)) {
        Equal   => Zero,
        Greater => Positive,
        Less    => Negative,
    }
}

Matching Constructors with Payloads

For union variants that carry a payload, bind it with parentheses. Use _ inside the parens to ignore the payload:

match List(7, 8, 9).first() {
    None    => "empty".print(Stdout),
    Some(_) => "non-empty".print(Stdout),
}

Why No if?

if cond then a else b is a match on Bool. Since you already need match for unions in general, a second branching construct would just be another way to do the same thing. So there is one.

Loops

Oneway has standard imperative loop constructs: while and for. Both are expressions of type Noop.

while

Bool = False | True

main = (Stdout) -> Noop {
    while False {
        "looping".print(Stdout)
    }
    "done".print(Stdout)
}

The condition is an expression of type Bool — which is just the two-variant union False | True. The body runs as long as the condition evaluates to True.

for

for iterates over a sequence. The exact iteration protocol is implementation-defined and may change.

Higher-Order Forms

For most collection work, prefer higher-order methods on the collection itself — map, fold, length, first, and friends — rather than explicit loops:

List(10, 20, 30)
    .map((Int) -> Int { Int.mul(2) })
    .length()
    .print(Stdout)

A while/for loop is the right answer when you need a side effect on each iteration; method chaining is the right answer when you are transforming a value.

Capabilities

A function's type should not lie about what it does. String.print = () -> Noop claims "nothing happens", but writing to stdout is something.

Oneway models effects as capabilities — values that must be passed in to perform an effect.

The Pattern

A function that prints requires Stdout:

String.print = (Stdout) -> Noop {
    Stdout.write(String)
}

A function that reads files requires Filesystem. A function that uses the clock requires Clock. And so on. The capability is just a type, passed as an ordinary argument.

Where Capabilities Come From

The only place to obtain real-world capabilities is main.ow, which receives them as parameters and threads them down to anything that needs them:

main = (Stdout) -> Noop {
    "hello".print(Stdout)
}

If a function does not receive a capability, it cannot perform the corresponding effect — it cannot even call something that does. Effects propagate through the type system: if f calls something needing Stdout, then f must take Stdout too.

Multiple Capabilities

A function that needs several capabilities receives them as a single product-typed parameter — the same & that composes product types elsewhere in the language:

use Filesystem

main = (Filesystem & Stdout) -> Result<Noop, IoError> {
    Filesystem.read(Path("Cargo.toml"))?.print(Stdout)
    Ok(Noop)
}

The components are accessed by their type names. The alphabetical-order rule that applies to product members also applies here: (Filesystem & Stdout) is valid; (Stdout & Filesystem) is a compile error.

Built-In Capabilities

The Oneway-owned core includes:

CapabilityEffectKind
ClockRead the current timenon-suspending
FilesystemRead and write filessuspending
NetworkOpen network connectionssuspending
RandomGenerate random valuesnon-suspending
StderrWrite to standard errornon-suspending
StdinRead from standard inputnon-suspending
StdoutWrite to standard outputnon-suspending

Binding packages add more capabilities of their own — HttpClient, HttpServer (suspending), Json (non-suspending), etc.

Suspending vs Non-Suspending

Capabilities split into two kinds based on whether their effects can wait on the outside world:

  • Non-suspending capabilities (Stdout, Clock, Random, …) complete without yielding to a scheduler.
  • Suspending capabilities (Filesystem, Network, …) may park the caller while the OS or a remote system responds.

A function compiles to async fn in Rust if and only if it transitively requires a suspending capability or calls an extern Rust.async item. Otherwise it compiles to a plain fn. main becomes #[tokio::main] only when the program is async-colored.

This is invisible at the source level — Oneway has no async keyword and no .await — but it carries the "color" of a function through the type system. The capability parameter is the color. Pure-compute programs that take no suspending capability link no async runtime, pay no state-machine overhead, and produce small binaries.

The propagation rule is the same as for any other capability: if you call something needing Filesystem, your function must declare Filesystem in its parameters. The compiler verifies; it does not infer the signature for you.

Why Not Monads?

A capability-passing model gives you the same honest type signatures as a monadic effect system, without introducing a separate kind of value or forcing all effectful code into a do-style block. Effects are just arguments. Composition is just method calls.

Modules

Oneway's module system is file-based and conventionally driven. There is no mod declaration, no manifest of what's in scope.

File Rules

  • Files are named snake_case.ow.
  • A file's name must match the type it declares: foo.ow must declare a type named Foo.
  • A module is a folder. There is no mod keyword.
  • The entry point is main.ow. A library's root is lib.ow.

Imports

To use a type defined in a sibling file, write:

use Foo

This imports Foo from foo.ow (or from the corresponding folder if Foo is a module). No paths, no aliasing.

Multiple use statements at the top of a file must be in alphabetical order.

Example: Multi-File Project

examples/multifile/
├── greeter.ow
└── main.ow

greeter.ow:

Greeter = String

Greeter.shout = () -> String {
    "HELLO from greeter"
}

main.ow:

use Greeter

main = (Stdout) -> Noop {
    Greeter("hi").shout().print(Stdout)
}

Run it with:

just example multifile

Visibility

Everything is public by default. To make a method private to its declaring file, prefix it with *:

Type.*helper = () -> Noop {
    ...
}

There is no pub keyword and no per-item visibility annotation beyond that single prefix.

Traits

A trait is a callable type signature. It is declared like a function type:

Show = () -> String

Because traits are types, they are written in PascalCase. The case difference is how the compiler distinguishes a trait implementation (Type.Print) from a regular method (Type.print) on the same type.

Implementing a Trait

A trait is implemented on a type by assigning to Type.TraitName:

Show = () -> String

Greeting = String
Name     = String

Greeting.Show = () -> String {
    "HELLO!"
}

Name.Show = () -> String {
    "Alice"
}

main = (Stdout) -> Noop {
    Greeting("hi").Show().print(Stdout)
    Name("Alice").Show().print(Stdout)
}

Greeting.Show() and Name.Show() both have the same signature (() -> String) and are called the same way.

Multi-Method Traits

A trait with multiple methods is just a product of single-method traits:

Show = Debug & PrintString

Default Implementations

A trait declaration can carry a default body marked { impl }:

Greet = () -> String { impl }

Implementing types may then either override or inherit the default.

Using a Trait as a Parameter

A trait can be used directly as a parameter type. The parameter binds the trait implementation, which is then invocable:

Type.needsPrint = (Print) -> Noop {
    Print()
}

Generic Constraints

Constraints on generic parameters use :, naming a trait the parameter must implement:

List.print = <T: Print>() -> Noop {
    ...
}

Errors

Errors are values, carried by the standard Result<T, E> type. The error slot is a regular type, so it can be a union written inline:

File.read = (Path) -> Result<Bytes, IoError | NotFound | PermissionDenied> {
    ...
}

This is more ergonomic than Rust's approach, where each call site typically needs a dedicated error enum.

The ? Operator

The postfix ? operator propagates failure. It works on both Result<T, E> and Option<T>:

  • On Result<T, E>: short-circuits with the error, otherwise unwraps to T.
  • On Option<T>: short-circuits with None, otherwise unwraps to T.
main = (Stdout) -> Result<Noop, Noop> {
    Ok(42)?.print(Stdout)
    match Some(7) {
        None    => "absent".print(Stdout),
        Some(_) => "present".print(Stdout),
    }
    Ok(Noop)
}

Ok(42)? evaluates to 42 (because the Result is Ok); if it were Err(_), the function would return early with that error.

Option vs Result

Option<T> and Result<T, Empty> are structurally similar but kept distinct:

  • None means absent.
  • Err(_) means failed.

The semantic difference is worth the duplication. Use Option when a value can legitimately be missing; use Result when an operation can legitimately fail.

Chaining

Because ? is postfix, error-propagating pipelines read top-down, left-to-right:

File.readConfig = (Path) -> Result<Config, IoError | ParseError> {
    File.read(Path)?
        .parse()?
        .validate()
}

Each ? unwraps the success case and lets the chain continue; the first failure short-circuits the whole function.

Validated Construction

The same ? shows up at the construction site for types whose constructor is fallible. A type with a Type.Self declaration that returns Result<Self, E> forces callers to handle the failure mode:

HttpClient.get(Url("https://example.com")?)?.print(Stdout)

Both ?s here are doing the same job: unwrapping a Result at the point of use. The first handles Url parsing failure (InvalidUrl); the second handles HttpClient.get failure (HttpError). The function's return type then carries the union: Result<Noop, HttpError | InvalidUrl>.

Error Naming

Errors are types like any other, and they should be named semantically — by what failed, not by who emitted them. InvalidUrl, MalformedJson, FileNotFound, PermissionDenied carry information; UrlError, JsonError, FsError don't.

The exception is opaque wrappers around foreign error types: when binding to a Rust crate whose error is a large enum with many variants, it's pragmatic to keep the wrapper opaque (e.g., HttpError for the entirety of reqwest::Error) until the underlying error space gets decomposed into proper Oneway variants.

Extern Rust

Oneway is batteries-included: opinionated binding packages for the major application domains (filesystem, HTTP, database, JSON, …) ship with the language. Each binding is implemented in ordinary Oneway via extern Rust declarations over a chosen Rust crate. There is no privileged path — anyone can write the same bindings; Oneway just ships a curated default so users don't have to.

The mechanism behind every binding is extern Rust, which lets an Oneway type or method be declared as backed by a Rust item. The transpiler emits direct calls — no runtime glue, no marshalling.

Declaring an Extern Method

extern Rust("std::cmp::min")
Int.min = (Int) -> Int

main = (Stdout) -> Noop {
    5.min(3).print(Stdout)
}

The string is the fully qualified Rust path. The Oneway signature declares how the method is called from Oneway.

A path that begins with . indicates a method call on the receiver, not a free function:

extern Rust(".to_lowercase")
String.toLower = () -> String

extern Rust(".to_uppercase")
String.toUpper = () -> String

main = (Stdout) -> Noop {
    "Hello, World".toUpper().print(Stdout)
    "GoodBye".toLower().print(Stdout)
}

Extern Types

A type alias to a Rust type is declared the same way, with no body:

extern Rust("std::io::Error")
IoError

extern Rust("reqwest::Error")
HttpError

The Oneway-side name (IoError, HttpError) is a transparent alias for the Rust type, suitable for use in Result<T, E> positions or anywhere else the Rust type is meaningful.

Async Externs

Async Rust functions are bound with extern Rust.async:

extern Rust.async("tokio::fs::read_to_string")
Filesystem.read = (Path) -> Result<String, IoError>

The compiler inserts .await at every call site, and the calling Oneway function is itself compiled as async fn. From the Oneway side, the call looks like any other method invocation — there is no async keyword and no .await. The async machinery is driven by the suspending-capability mechanism (see Capabilities): a function that receives a suspending capability or calls a Rust.async extern is compiled to async Rust automatically.

An extern Rust.async declaration is valid only on a method whose receiver or parameters include a suspending capability — typically Network or Filesystem. This keeps the capability set honest: async effects must be reflected in the type, not slipped in through an extern declaration.

Dependency Manifest

Each Oneway project carries a manifest listing the Rust crates it depends on. The transpiler emits a Cargo.toml that mirrors it, and oneway build is a thin wrapper around cargo build.

[deps]
axum       = "0.7"
serde_json = "1"
sqlx       = "0.7"

For programs that only use shipped binding packages, the manifest is empty — each binding pulls in its own crate deps automatically when imported.

Binding Packages

Idiomatic Oneway code does not call extern Rust directly. Instead, it imports from the shipped binding packages:

use Filesystem    # wraps tokio::fs
use HttpClient    # wraps reqwest
use HttpServer    # wraps axum
use Database      # wraps sqlx
use Json          # wraps serde_json

A binding package is a few hundred lines of Oneway declarations plus minimal ergonomic glue, written once and shipped with the language. The community can publish additional or alternative bindings; the shipped set is just the curated default.

Tradeoffs

  • Error messages may leak Rust types when crossing the FFI boundary. Unavoidable to some degree; mitigated by good bindings.
  • Async-flavored crates are bound via Rust.async externs and the suspending-capability mechanism, so the no-keyword promise is preserved while still using async crates natively. The cost — tokio in the dep tree, state-machine codegen — is paid only by programs that actually take a suspending capability.
  • Oneway is permanently coupled to Rust unless a second backend is later added. A real strategic dependency, accepted in exchange for sharing the entire Rust ecosystem.

Sort Order

The guiding rule: wherever ordering is discretionary, the compiler enforces alphabetical order.

Where It Applies

ConstructOrder
Components of a product typeAlphabetical
Variants of a union typeAlphabetical
Multiple methods on a typeAlphabetical
Trait composition (Show = A & B)Alphabetical
Error union inside Result<T, E>Alphabetical
use statements at the top of a fileAlphabetical
Arms of a matchOrder of the union's variants (alphabetical)

Examples

A product type:

User = Birthday & Username

A union type:

Ord = Equal | Greater | Less

Multiple methods on the same type:

User.add    = (...) -> ...
User.export = (...) -> ...
User.remove = (...) -> ...

Inline error union:

File.read = (Path) -> Result<Bytes, IoError | NotFound | PermissionDenied> {
    ...
}

Match arms in variant order:

match ord {
    Equal   => ...,
    Greater => ...,
    Less    => ...,
}

Checking Without Compiling

just check path/to/file.ow

This runs only the sort-order check, with no codegen. Useful in pre-commit hooks or as a quick lint while editing.

Rationale

Ordering is a constant source of bikeshedding and diff noise. By forcing one canonical order, code reads the same way no matter who wrote it, and reordering is never a meaningful change.

Operators

Type-Level Precedence

Tightest first:

  1. T[N] — postfix repetition
  2. ...T — prefix spread
  3. T<...> — generic application
  4. & — product
  5. | — union

So A | B & C[3] parses as A | (B & (C[3])).

Expression-Level Precedence

Tightest first:

  1. . — method call / field access
  2. () — function application
  3. ? — postfix error propagation
  4. & — value-level product (only inside a constructor argument)

So foo.bar()? is ((foo.bar)())?.

Glossary of Operators and Sigils

SymbolMeaning
|Union
&Product
Type[N]Fixed repetition (N copies)
...TypeUnbounded repetition
<T>Generic parameter
<T: Tr>Generic with trait constraint
::<T>Type argument at a call site (turbofish)
.Method call / field access
?Propagate Result / Option failure
*namePrivate method (file-local)
"..."String literal sugar
mutMutable parameter

::<T> after a method name pins a generic method's type parameter when the compiler cannot infer it from context:

Json.parse::<List<Int>>("[1, 2, 3]")?

Coming from Rust

Oneway transpiles to Rust and inherits its execution model, but the surface syntax is quite different. If you already know Rust, this page is the fastest path in.

Cheat Sheet

RustOneway
struct User { birthday: ..., username: ... }User = Birthday & Username
enum Bool { False, True }Bool = False | True
type Name = String; (newtype via struct Name(String);)Name = String
impl User { fn greet(&self) -> String { ... } }User.greet = () -> String { ... }
fn main() { ... }main = (Stdout) -> Noop { ... }
trait Show { fn show(&self) -> String; }Show = () -> String
impl Show for User { ... }User.Show = () -> String { ... }
Result<T, E>Result<T, E> (same name; inline union for E)
Option<T>Option<T>
? operator? operator (same semantics)
match x { ... }match x { ... }
let x = ...;No equivalent — declare a newtype
if cond { a } else { b }match cond { False => b, True => a }
pub fnPublic by default; *name is private
mod foo;No modfoo.ow declares Foo
use crate::foo::Foo;use Foo
fn(...) -> T (function type)(params) -> T (also a trait declaration)
&T / &mut T / Box<T> / Rc<T>Inferred by the transpiler

Things Rust Has That Oneway Doesn't

  • Lifetimes and borrow sigils ('a, &, &mut). Ownership is inferred from usage.
  • Comments. Use names and types.
  • if/else. Use match on Bool.
  • let and local variables. Method chaining only; newtype an intermediate value if you really need to name it.
  • Named arguments. Use newtypes for disambiguation.
  • Macros and format!. No comparable mechanism yet.
  • async/await. Concurrency is uniform — see DESIGN.md.

Things Oneway Has That Rust Doesn't

  • Mandatory alphabetical declaration order. Compiler-enforced.
  • Effects as capabilities. Side effects flow through ordinary arguments rather than unsafe, globals, or library wrappers.
  • Inline error unions. Result<Bytes, IoError | NotFound> without declaring a wrapper enum at every call site.
  • No-comments policy. The compiler rejects them.

When in Doubt

  • Look at the examples/ directory in the repo.
  • Read DESIGN.md — it's the authoritative spec.
  • just emit path/to/file.ow prints the Rust the transpiler produces.