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, noif/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
matchon a union. - Side effects are passed in as capabilities (
Stdout,Filesystem, …). - Imports are file-based:
use Fooimports the type declared infoo.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 justor 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
| Path | What it is |
|---|---|
src/ | The compiler (lexer, parser, checker, codegen). |
examples/ | Sample .ow programs. |
editors/ | Tree-sitter grammar and Zed extension. |
DESIGN.md | The 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.
Stdoutis a capability. Real-world capabilities only exist inmain, which receives them and threads them down to anything that needs to perform a side effect.Noopis a singleton type — a type with exactly one value, named after itself. ReturningNoopis 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
Stdoutfrommain's parameters. The compiler will complain whenprintis 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
usestatements 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:
| Kind | Constructor | Argument is… |
|---|---|---|
| Primitive | Int(123), Float(1.0), String("hi") | a literal of the corresponding lexical kind |
| Hex | Hex(0xFF0000) | a hex literal |
Product A & B | T(A(...) & B(...)) | a value-level product joined with & |
Union A | B | T(A(...)) or T(B(...)) | a value of any variant |
| Newtype | T(inner) | a value of the aliased type |
Literal Sugar
A handful of literals desugar to their constructors:
| Literal | Desugars to |
|---|---|
123 | Int(123) |
1.0 | Float(1.0) |
"abc" | String("abc") |
0xFF0000 | Hex(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.
matchis an expression — it can be the final line of a body or appear as a sub-expression.whileandforare expressions of typeNoop.- 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:
| Capability | Effect | Kind |
|---|---|---|
Clock | Read the current time | non-suspending |
Filesystem | Read and write files | suspending |
Network | Open network connections | suspending |
Random | Generate random values | non-suspending |
Stderr | Write to standard error | non-suspending |
Stdin | Read from standard input | non-suspending |
Stdout | Write to standard output | non-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.owmust declare a type namedFoo. - A module is a folder. There is no
modkeyword. - The entry point is
main.ow. A library's root islib.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 toT. - On
Option<T>: short-circuits withNone, otherwise unwraps toT.
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:
Nonemeans 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.asyncexterns 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
| Construct | Order |
|---|---|
| Components of a product type | Alphabetical |
| Variants of a union type | Alphabetical |
| Multiple methods on a type | Alphabetical |
Trait composition (Show = A & B) | Alphabetical |
Error union inside Result<T, E> | Alphabetical |
use statements at the top of a file | Alphabetical |
Arms of a match | Order 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:
T[N]— postfix repetition...T— prefix spreadT<...>— generic application&— product|— union
So A | B & C[3] parses as A | (B & (C[3])).
Expression-Level Precedence
Tightest first:
.— method call / field access()— function application?— postfix error propagation&— value-level product (only inside a constructor argument)
So foo.bar()? is ((foo.bar)())?.
Glossary of Operators and Sigils
| Symbol | Meaning |
|---|---|
| | Union |
& | Product |
Type[N] | Fixed repetition (N copies) |
...Type | Unbounded 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 |
*name | Private method (file-local) |
"..." | String literal sugar |
mut | Mutable 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
| Rust | Oneway |
|---|---|
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 fn | Public by default; *name is private |
mod foo; | No mod — foo.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. UsematchonBool.letand 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.