Wado

WEP: Default Arguments

Context

Wado currently requires all function arguments and all struct fields to be specified explicitly at every call site and construction site. This creates verbosity in two major areas.

WebIDL Bindings

wado-from-idl generates Wado bindings for browser APIs (The Web Interface for Wado). WebIDL makes heavy use of optional parameters with default values:

Node cloneNode(optional boolean deep = false);
undefined addEventListener(DOMString type, EventListener? callback,
                           optional AddEventListenerOptions options);
undefined fillText(DOMString text, double x, double y, optional double maxWidth);

Without default arguments, these map to Option<T> parameters, which is verbose and introduces runtime overhead (Option wrapping/unwrapping):

// Current: Option<T> mapping
fn clone_node(&self, deep: Option<bool>) -> Node;
fn fill_text(&self, text: String, x: f64, y: f64, max_width: Option<f64>);

node.clone_node(Option::<bool>::Some(false));
ctx.fill_text("Hello", 50.0, 150.0, null);

WebIDL dictionaries have the same problem. All dictionary members are optional:

dictionary RequestInit {
  ByteString method;
  BodyInit? body;
  RequestMode mode;
};
// Current: all fields are Option<T>
let init = RequestInit {
    method: Option::<String>::Some("POST"),
    body: Option::<String>::Some(data),
    headers: null,
    mode: null,
    credentials: null,
};

General Ergonomics

Beyond WebIDL, many APIs have parameters with natural default values:

// Without defaults: multiple function names or Option wrapping
fn connect(host: String, port: i32, timeout: i32) { ... }
fn connect_default(host: String) { connect(host, 8080, 30); }

Design Requirements

Cross-Language Survey

Language Approach Evaluation Named Args
Swift Inline defaults Call-site Yes (labels)
Kotlin Inline defaults Call-site (bitmask) Yes
Zig Struct field defaults Construction-site No (field names)
Python Inline defaults Definition-time Yes
C++ Inline defaults Call-site No
Rust None (builder pattern) — No

Key lessons:

Decision

Function Parameter Defaults

Trailing parameters may have default values:

fn connect(host: String, port: i32 = 8080, timeout: i32 = 30) { ... }

connect("localhost");           // → connect("localhost", 8080, 30)
connect("localhost", 3000);     // → connect("localhost", 3000, 30)
connect("localhost", 3000, 60); // no desugaring

Syntax

fn name(param: Type, param: Type = expr, ...) -> ReturnType { ... }

Parameters with defaults must come after all parameters without defaults. This is enforced at parse time.

fn foo(a: i32 = 0, b: i32) { ... }  // compile error: non-default param after default

The self parameter cannot have a default.

Semantics: Call-Site Expansion

The compiler inserts default values at the call site as a desugaring pass. This is purely a compile-time transformation — no runtime cost.

fn greet(name: String, greeting: String = "Hello") -> String {
    return `${greeting}, ${name}!`;
}

// Source:
greet("Alice");
// After desugaring (identical Wasm):
greet("Alice", "Hello");

Default Expressions

Any expression without effects is allowed as a default:

fn foo(
    x: i32 = 0,                      // literal
    y: f64 = f64::PI,                 // associated constant
    z: String = `default`,            // template string
    w: Option<Config> = null,         // null (coerces to Option::None)
    v: Color = Color::Red,            // enum variant
    u: i32 = i32::max(1, 2),          // pure function call
) { ... }

// Error: effectful expression in default
fn bar(path: String = read_env("PATH")) { ... }  // compile error: requires effects

The restriction is enforced by the effect system: default expressions must have an empty effect set.

Name Resolution: Declaration Scope

A default expression resolves its names in the scope of the declaration (the function's, method's or struct's defining module), not the use site. Call-site expansion inserts the expression, but the names inside it bind exactly as they would at the declaration:

Compile-time literals that read a file follow the declaration too: #include_str, #include_bytes and #data all name the file that wrote them, which is the only file whose paths and __DATA__ section the loader read.

Two exceptions are deliberate and syntactic, not scoped lookups. Location literals (#file/#line/#function) evaluate at the call site; see the interaction table below. And a default may name an earlier parameter, covered in the next section. That name means the value the call supplied for it. The caller built that value in its own scope, so it may have read its own locals to do so, but nothing else in the default sees the caller: every other name still resolves at the declaration.

The same rule governs a type parameter's default (<T = Priv>): it names a type in the declaring module's scope, which the use site may not be able to name at all. A parameter the use site declares therefore never answers for it, however the two are spelled (generic_type_param_defaults.wado).

Two rules make such a default expandable at all, both checked once per declaration rather than at each application that reaches it:

Both resolvers ask, since a default is reached from an annotation and from a struct field alike.

This mirrors Kotlin/Swift/C#: encapsulation of the declaring module is preserved, and a default behaves identically at every use site.

What a default may name, and the fixture pinning each. default_arg_scope.wado and default_field.wado each carry one test block per row, named after the rule, and cross a module boundary in every one — the direction that can tell the declaration's scope from the taking site's.

What the default names Fixture
A private item of the declaring module default_arg_scope.wado, default_field.wado
A private type of that module default_arg_scope.wado
A variant case across the boundary default_arg_scope.wado, default_field.wado (issue #1486)
That module's import alias (ns::x) default_arg_scope.wado, default_field.wado
An earlier parameter default_arg_scope.wado, default_arg_sites.wado, default_arg_earlier_param_once.wado, default_arg_mut_ref_place.wado
The declaration's own type parameter default_arg_callee_type_param.wado
A location literal (call site) default_arg_location_call_site.wado
A file literal (declaring file) default_arg_scope.wado
Nothing of the caller's default_arg_scope.wado

A method's default is resolved where declared rather than where called: default_arg_scope.wado, "a method's default resolves where it is declared".

Default expressions may reference earlier parameters in the same function:

fn make_rect(width: f64, height: f64 = width) -> Rect { ... }

make_rect(10.0);        // → make_rect(10.0, 10.0)
make_rect(10.0, 20.0);

The argument is evaluated once, in its place among the others. Where a default names a parameter, the call binds its arguments to locals in order. The default then reads the local in whatever form it takes, including a closure that captures it. A default that another default names is bound the same way. A call whose defaults name no parameter binds nothing. Where a call does bind locals, the optimizer folds them away (default_arg_earlier_param_once.wado):

fn twice(a: i32, b: i32 = a + a) -> i32 { ... }

twice(next());          // → { let a = next(); twice(a, a + a) }

The receiver runs first too. A receiver that is a place stays in its slot, because a &mut self method writes there. Only its subscripts are bound, so v[i()].m(j()) still runs i() before j(). Any other receiver is bound whole.

A &mut borrow of a place stays in its slot the same way, for the callee's writes to reach the place, and a default naming its parameter borrows the place again (default_arg_mut_ref_place.wado):

fn put(o: &mut Option<i32>, v: i32 = peek(o) + 1) { ... }

put(&mut s.o);          // → put(&mut s.o, peek(&mut s.o) + 1)

A closure cannot hold such a borrow, so where a closure in a default names the parameter, the borrow is bound like any other argument.

Interaction with Function Types

Function types do not carry default information. Assigning a function with defaults to a function type erases the defaults:

fn connect(host: String, port: i32 = 8080) { ... }

let f: fn(String, i32) = connect;  // OK: full signature
f("localhost", 3000);              // OK
f("localhost");                    // compile error: expected 2 arguments, found 1

// The arity is part of the type — no defaults in fn types
let g: fn(String) = connect;      // compile error: type mismatch

This is a direct consequence of zero overhead: function types represent the actual call signature at the Wasm level.

Interaction with Closures

Closures cannot declare default parameters. Closures are first-class values whose type is fn(...), which carries arity but not defaults. Any call site of a closure must supply every argument.

// Compile error: closure parameters cannot have defaults.
let greet = |name: String, greeting: String = "Hello"| `${greeting}, ${name}!`;

The parser still accepts the = expr syntax for closure parameters so that recovery is uniform with named functions, but the elaborator rejects it with an error that points at the default expression. Use a wrapper function with defaults instead:

fn greet(name: String, greeting: String = "Hello") -> String {
    return `${greeting}, ${name}!`;
}

Rationale: closures erase defaults the instant they are assigned to a fn(...) variable, so permitting defaults at the closure literal site would create inconsistent call-site behavior (same closure, different call sites, different argument counts) and would imply a non-trivial desugaring for captured state. Keeping defaults out of closures preserves the invariant that "a closure value and its type agree on arity".

Interaction with Trait Methods

Only the trait definition may specify default arguments. Implementations cannot add, remove, or change defaults:

trait Drawable {
    fn draw(&self, opacity: f64 = 1.0);
}

impl Drawable for Circle {
    fn draw(&self, opacity: f64) {
        // impl receives all parameters — no default syntax here
    }
}

let c = Circle { radius: 5.0 };
c.draw();      // → c.draw(1.0)   — trait's default applied at call site
c.draw(0.5);

Rationale: the caller sees the trait's interface, so the trait owns the defaults. This avoids ambiguity about which defaults apply when calling through a trait reference, and prevents implementations from silently changing call-site behavior.

The rule covers a type parameter's default the same way. The trait declares it, the impl restates the parameter without it, and an omitted turbofish takes the trait's:

// in another module: `Tag` is private to it
pub trait Boxed {
    fn boxed<T: Named = Tag>(&self) -> String;
    fn made<T: Named = Tag>() -> String;
}

impl Boxed for M {
    fn boxed<T: Named>(&self) -> String {   // no default here
        return T::name();
    }

    fn made<T: Named>() -> String {
        return T::name();
    }
}

m.boxed();          // → m.boxed::<Tag>()  — a type the caller cannot name
m.boxed::<Local>(); // a spelled turbofish still wins
M::made();          // the static spelling fills it from the same declaration

Both defaults reach every spelling a call takes: the instance form, Type::method(), Type::<A>::method(), ns::Type::method(), T::method() through a bound, and a value blanket's statics. An impl that writes either default is rejected (DefaultInTraitImpl, TypeParamDefaultInTraitImpl); a trait's own default-bodied method is the declaration, so it may write both.

If a method is not part of a trait (a direct impl method), it may freely define defaults:

impl Circle {
    fn scale(&mut self, factor: f64 = 2.0) {
        self.radius = self.radius * factor;
    }
}

Interaction with Component Model Exports

export fn cannot declare default parameters. Exported functions appear in the component's public interface (WIT), where every parameter is required by the CM ABI. Permitting defaults only on the Wado side would create a divergence between the WIT signature and the in-language signature that must be reconciled at every tool boundary (wado doc, IDE hover, CM host bindings). Rather than carry that inconsistency, Wado rejects the default outright.

// Compile error: export fn cannot have default parameters.
export fn configure(name: String, debug: bool = false) { ... }

The parser still accepts the syntax so that the error message can point at the default expression. The fix is either to drop the default, or to split into a private helper with the default and a thin export fn wrapper:

fn configure_impl(name: String, debug: bool = false) { ... }

export fn configure(name: String, debug: bool) {
    configure_impl(name, debug);
}

Operations on an interface or a resource

An operation may declare a parameter default. A call that omits the argument is desugared at the call site like any other. What the operation dispatches to always receives the full argument list, so it never sees a default: a Component Model import for a #[cm] operation, a dispatch wrapper for an effect operation.

The export fn rule above does not apply here, because the direction is reversed. There the CM ABI fixes the signature Wado itself exports. Here it fixes the callee's, and the default lives on the caller's side of it. That is what lets a WebIDL optional argument carry its declared default (see The Web Interface for Wado).

A default resolves in the module that declares the operation, so it may name that module's private items. The call that materializes it is minted at each call site, so the declaring module holds the default expression but no call reaching it. Liveness seeds the operation itself to supply that edge.

Struct Field Defaults

Struct fields may have default values:

struct Config {
    host: String,
    port: i32 = 8080,
    timeout: i32 = 30,
    debug: bool = false,
}

Implicit Omission

Fields with defaults may be omitted in struct construction. Fields without defaults are required:

let c = Config { host: "localhost" };
// Desugars to: Config { host: "localhost", port: 8080, timeout: 30, debug: false }

let c = Config { host: "localhost", port: 3000 };
// Desugars to: Config { host: "localhost", port: 3000, timeout: 30, debug: false }

Config { port: 3000 };  // compile error: missing required field 'host'

No special syntax marker (such as ..) is needed. This follows the same model as Zig, Swift, Kotlin, Dart, and Scala.

Omission is safe because struct construction uses named fields — the compiler checks that all provided field names are valid and all required fields are present.

Default Expressions

Same rules as function parameter defaults: any expression without effects.

struct Circle {
    center: Point = Point { x: 0, y: 0 },
    radius: f64 = 1.0,
}

let unit_circle = Circle {};  // center at origin, radius 1.0

Unlike function parameter defaults, struct field default expressions cannot reference other fields.

Interaction with Shorthand Construction

Field shorthand (variable name matches field name) works with defaults:

let host = "localhost";
let c = Config { host };
// Desugars to: Config { host: host, port: 8080, timeout: 30, debug: false }

Interaction with Destructuring

Defaults do not affect destructuring. When destructuring, all fields are available:

let Config { host, port, timeout, debug } = config;  // all fields accessible
let Config { host, .. } = config;                     // ignore rest

Interaction with Default Trait

A struct where all fields have defaults automatically implements Default:

struct Config {
    host: String = "localhost",
    port: i32 = 8080,
}

// Auto-derived (the compiler synthesizes this):
// impl Default for Config {
//     fn default() -> Config {
//         return Config { host: "localhost", port: 8080 };
//     }
// }

let c = Config::default();

A struct with any required field (no default) does not auto-derive Default.

Interaction with Serde

A field default makes the field optional on deserialize, falling back to that default when absent. This extends the "has default → optional" rule from construction to deserialization, and is the single mechanism for an optional field (#[wire(default)] is removed):

struct Config {
    port: i32 = 8080,  // serde uses 8080 when the field is missing from JSON
}

See the serde WEP's Default Values for Missing Fields.

Naming the Declaration's Own Type Parameter

A value default may name a type parameter of the declaration that wrote it:

fn info<T: Default>(msg: String, fields: T = T::default()) -> String { ... }

info::<i32>("count");     // fields = 0

The default resolves once per site that takes it, with the declaration's type parameters standing for the type arguments that site settled on. T is a concrete type by the time T::default() is looked up, so the call dispatches statically on it. Where the argument is itself a parameter — a caller forwarding its own X — T stands for that binder and the default monomorphizes with the caller, as any body does.

Each site's walk is kept apart from every other's. Two calls of one generic callee read the one default expression at different types, and a walk answering for both would hand a site the other's value.

Coverage

Rows are the kinds of site that take a default; columns are where the type argument comes from. default_arg_callee_type_param.wado walks every ✅ at two type arguments, in the declaring module and across a module boundary.

Site Turbofish Argument or field beside it The parameter's own type default Caller's own binder
Free function ✅ ✅ ✅ ✅
Instance method — impl's param ✅ ᴿ ✅ ᴿ ✅ ᴿ ✅
Instance method — method's own ✅ ✅ ✅ ✅
Static method — impl's param ✅ ᴿ ✅ ᴿ ✅ ᴿ ✅
Static method — method's own ✅ ✅ ✅ ✅
Trait method — trait's own param ✅ ✅ ✅ ✅
Trait method — method's own ✅ ✅ ✅ ✅
Struct field ✅ ᴬ ✅ ✅ ᴬ ✅
Free function — variadic pack ✅ ✅ — ✅
Instance method — variadic pack ✅ ✅ — ✅
Static method — variadic pack ✅ — — ✅

ᴿ The receiver settles the impl block's parameters, so the column describes how the receiver's own type was settled. ᴬ The literal's annotation names the instantiation.

A variadic pack is a type parameter like any other, so [..T::default()] is a default the same way T::default() is. A pack settles to a tuple rather than to a single type. Nothing records that tuple except the parameter the default fills, so reify reads it off there and stamps it on the expansion node. A monomorphizer pass ahead of instantiation then expands the node from the stamp, in every body of the module: a global initializer takes a default as a function body does. Without it the spliced default waits for a substitution only a generic caller performs, and a concrete caller reaches lowering with the node still standing. A pack declares no type default of its own, so that column is empty for every pack row.

A turbofish spells one type argument per pack element, so the arguments are grouped into slots before anything counts them. A scalar parameter ahead of the pack keeps the argument written for it.

A pack stands for the type arguments left over, so a call leaving none over settles it to the empty pack. A turbofish naming every slot ahead of the pack and stopping there leaves none (headed::<i32>()), and so does a call whose written arguments pin nothing for it (packed("e:")). An effect parameter and an fn-bound one hold no slot, so neither is a turbofish's to name. A caller forwarding a pack of its own is neither. Such a pack interns to the same type as the callee's, so a scope holding one leaves the slot alone.

The method's own type default is settled before the value defaults are walked, against the same slots the call's own inference uses. It runs only where the method declares a value default: resolving one is what needs the receiver's associated types registered (Iterator::collect writes <C: FromIterator<Elem = Self::Item> = List<Self::Item>>), and that registration is not safe this early.

An interface operation is absent by rule, not by omission: an operation declares neither parameter defaults nor type parameters (see Operations on an interface or a resource).

WebIDL Mapping Improvement

With default arguments, WebIDL optional parameters with explicit defaults map to their actual types instead of Option<T>:

// WebIDL: optional boolean subtree = false
// Before (Option wrapping):
fn clone_node(&self, deep: Option<bool>) -> Node;
// After (default value from IDL):
fn clone_node(&self, deep: bool = false) -> Node;

// WebIDL: optional boolean counterclockwise = false
fn arc(&self, x: f64, y: f64, radius: f64,
       start_angle: f64, end_angle: f64, anticlockwise: bool = false);

WebIDL optional parameters without explicit defaults map to Option<T> with = null:

// WebIDL: optional double maxWidth (no default in IDL)
fn fill_text(&self, text: String, x: f64, y: f64, max_width: Option<f64> = null);

WebIDL optional parameters with = {} (empty dictionary default) map to struct type with struct default:

// WebIDL: optional RequestInit init = {}
fn fetch(input: String, init: RequestInit = RequestInit {}) -> Future<Response>;

// WebIDL: optional AddEventListenerOptions options = {}
fn add_event_listener(&self, type: String, callback: Option<fn(Event)>,
                      options: AddEventListenerOptions = AddEventListenerOptions {});

WebIDL dictionaries map to structs with field defaults. Members with explicit defaults use the declared default; members without defaults use Option<T> = null:

// WebIDL:
// dictionary RequestInit {
//   ByteString method;          ← no default in IDL
//   BodyInit? body;             ← no default in IDL
//   RequestMode mode;           ← no default in IDL
// };

// Before:
pub struct RequestInit {
    pub method: Option<String>,
    pub body: Option<String>,
    pub mode: Option<RequestMode>,
}

// After: all fields Option<T> = null (no IDL defaults to use)
pub struct RequestInit {
    pub method: Option<String> = null,
    pub body: Option<String> = null,
    pub mode: Option<RequestMode> = null,
}

// Dictionary WITH explicit defaults:
// dictionary ResponseInit {
//   unsigned short status = 200;    ← default in IDL
//   ByteString statusText = "";     ← default in IDL
//   HeadersInit headers;            ← no default
// };
pub struct ResponseInit {
    pub status: u16 = 200,
    pub status_text: String = "",
    pub headers: Option<Headers> = null,
}

User code becomes significantly cleaner:

// Before:
let init = RequestInit {
    method: Option::<String>::Some("POST"),
    body: Option::<String>::Some(data),
    mode: null,
};
let resp = Fetch::fetch(url, Option::Some(init)).read();

// After: struct field defaults eliminate boilerplate, fetch default is {}
let init: RequestInit = { method: Option::Some("POST"), body: Option::Some(data) };
let resp = Fetch::fetch(url, init).read();

Consequences

Benefits

Trade-offs

Interaction with Existing Features

Feature Interaction
Effect system Default expressions must be pure (no effects)
Traits Only trait definition specifies defaults
Function types Defaults erased — arity fixed
Closures Defaults rejected — parsed but error in elaborator
export fn (CM) Defaults rejected — arity must match WIT signature
Default trait Auto-derived for all-defaulted structs
Serde Declared field default → optional on deserialize
Generics Default expressions are monomorphized per call site
Location literals #file/#line/#function defaults evaluate at the call site
File literals #include_str/#include_bytes/#data read the declaring file

Implementation Roadmap

Known gaps

A call that pins no type argument at all reports the parameter it could not infer, preceded by an unknown function 'T::default' from the default walk that had nothing to resolve against. The second diagnostic is the one to read.

A closure in a default that names a &mut parameter holds the borrow in a local, so a call borrowing a field that holds a variant there is refused as a variable holding the detached copy past the borrow, where spelling the closure at the call (|| peek(&s.o)) is accepted.

See Also