Wado

WEP: Conversion Traits (From, TryFrom, ? operator)

Context

Wado currently has two conversion mechanisms:

There is no general-purpose mechanism for converting between user-defined types, and the ? operator (already reserved in the spec) has no implementation. The two key motivations for this WEP are:

  1. Error type composition: Enable the ? operator for ergonomic error propagation with automatic error conversion
  2. Structured conversions: Provide a standardized protocol for user-defined type conversions

Language Survey

See Research: From/Into Conversion Trait Framework for the full survey. Key findings:

Approach Languages Outcome
Implicit conversions C++, Scala 2 Universally regretted; causes bugs, hides costs
Fully explicit Go, Zig Safe but verbose; no generic conversion protocol
Trait-based, call-site-explicit Rust Good balance; From/? integration is the standout success
Literal coercion only Swift Elegant for literals, insufficient for value-to-value

Rust's From/Into framework is the primary reference design. Its strengths: standardized conversion protocol, ? operator integration, separation of infallible/fallible. Its weaknesses:

Design Goals

  1. Support the ? operator with From-based error conversion
  2. Keep target types visible at every call site
  3. Minimize the number of conversion traits (Wado's GC eliminates the need for AsRef/Borrow/Deref)
  4. Support compiler-generated impls for variants and newtypes
  5. No implicit conversions beyond existing literal coercion

Decision

1. From<T> Trait

Infallible conversion trait in core:prelude:

#[comp_feature("from")]
pub trait From<T> {
    fn from(value: T) -> Self;
}

Usage is always explicit via Type::from(value):

impl From<i32> for String {
    fn from(value: i32) -> String {
        return `${value}`;
    }
}

let s = String::from(42);  // "42"

The #[comp_feature("from")] attribute registers From in the compiler's TypeTable, enabling the ? operator to look up From impls for error conversion.

Reflexive impl: The compiler provides impl From<T> for T for all types (identity conversion). This is needed for ? to work when the error type already matches.

2. TryFrom<T> Trait

Fallible conversion trait returning Result:

pub trait TryFrom<T> {
    type Err: Error;
    fn try_from(value: T) -> Result<Self, Self::Err>;
}

The associated type is Err, as FromStr's is: it names the Result's Err side, and type Error would give the name two meanings inside an impl that also has the Error trait in scope.

Err: Error makes every conversion failure reportable. A caller reaching the failure through the bound has no impl to read, so without it the value it holds says nothing:

fn reason<T: TryFrom<i32>>(value: i32) -> String {
    return match T::try_from(value) {
        Ok(_) => "converted",
        Err(e) => `${e}`,       // `Error: Display`
    };
}
impl TryFrom<i64> for u8 {
    type Err = ConvertError;
    fn try_from(value: i64) -> Result<u8, ConvertError> {
        if value < 0 || value > 255 {
            return Result::Err(ConvertError::new(`${value} out of range for u8`));
        }
        return Result::Ok(value as u8);
    }
}

let n = u8::try_from(42 as i64)?;  // Ok(42)
let n = u8::try_from(999 as i64)?; // Err: a ConvertError reading "999 out of range for u8"

ConvertError is what every stdlib impl uses. An impl may define its own error type instead, which needs an impl Display and the empty impl Error for T;.

3. ? Operator

The ? postfix operator (already reserved in the spec's operator table) provides early return for Result and Option.

On Result<T, E>: Unwrap Ok(v) → v, or convert the error and return early:

// Desugars to:
// match expr {
//     Ok(v) => v,
//     Err(e) => { return Result::Err(From::from(e)); },
// }

fn process() -> Result<i32, AppError> {
    let data = read_file("input.txt")?;   // IoError → AppError via From
    let n = parse_int(data)?;              // ParseError → AppError via From
    return Result::<i32, AppError>::Ok(n);
}

The error conversion From::from(e) is the key integration point: ? calls From to convert the inner error type to the enclosing function's error type.

On Option<T>: Unwrap Some(v) → v, or return null (which is Option::None):

// Desugars to:
// match expr {
//     Some(v) => v,
//     None => { return null; },
// }

fn find_user_name(id: i32) -> Option<String> {
    let user = find_user(id)?;        // None → return null
    let profile = user.profile()?;    // None → return null
    return Option::<String>::Some(profile.name);
}

Requirements:

Effect interaction: The ? operator performs early return but does not introduce effects. The function's with clause is unaffected.

4. No Into<T> Trait

Wado does not provide an Into<T> trait. The reasons:

  1. .into() hides the target type. Code like let x = value.into() requires type context to understand. String::from(value) is always clear.

  2. Orphan rules make Into unnecessary. Rust needed Into because pre-1.41 orphan rules prevented impl From<ForeignType> for LocalType in some cases. Wado uses Rust 1.41+ style orphan rules where having a local type in any trait parameter position suffices. With From<LocalType> for ForeignType allowed, there's no need for Into.

  3. impl Into<T> parameters cause monomorphization bloat. Each unique caller type generates a copy of the function body. The Rust ecosystem created the momo crate specifically to work around this problem.

Users write Type::from(value) at call sites. This is slightly more verbose than .into() but always makes the target type visible.

5. No as Extension for From

The as operator retains its current semantics:

as does not call From::from(). The semantic distinction:

Mechanism Semantics Cost
as Bit reinterpretation / cast Zero-cost, always succeeds
From::from() Semantic conversion May allocate, always succeeds
TryFrom::try_from() Fallible conversion May allocate, may fail

This keeps as simple and predictable. Developers know x as T is always a trivial operation.

6. Variant From Auto-Derive

Variant types can opt into compiler-generated From impls using the existing auto-derive syntax:

variant AppError {
    Io(IoError),
    Parse(ParseError),
    Validation(String),
}

impl From<IoError> for AppError;      // generates: AppError::Io(value)
impl From<ParseError> for AppError;   // generates: AppError::Parse(value)
impl From<String> for AppError;       // generates: AppError::Validation(value)

Each impl From<T> for Variant; (with no body) instructs the compiler to generate:

impl From<IoError> for AppError {
    fn from(value: IoError) -> AppError {
        return AppError::Io(value);
    }
}

This follows the existing pattern used by impl Serialize for Type; and impl Deserialize for Type;.

Ambiguity rule: If multiple variant cases have the same payload type, the compiler rejects the auto-derive with a clear error message. The user must write the From impl manually.

This pattern is especially powerful with ?:

fn process() -> Result<String, AppError> {
    let data = read_file("input.txt")?;   // IoError → AppError::Io via From
    let parsed = parse(data)?;            // ParseError → AppError::Parse via From
    return Result::<String, AppError>::Ok(parsed);
}

7. Newtype From Auto-Generation

The compiler automatically generates bidirectional From impls for newtypes:

type UserId = u64;
// compiler auto-generates:
// impl From<u64> for UserId { fn from(value: u64) -> UserId { return value as UserId; } }
// impl From<UserId> for u64 { fn from(value: UserId) -> u64 { return value as u64; } }

This means both as and From::from() work for newtypes:

let id: UserId = 42;                    // literal coercion (existing)
let id2 = 42 as UserId;                 // as cast (existing)
let id3 = UserId::from(42 as u64);      // From (new, auto-generated)
let raw = u64::from(id);                // From (new, auto-generated)

The auto-generated From impls enable newtypes to participate in generic code:

fn print_id<T: From<u64>>(raw: u64) {
    let id = T::from(raw);
    println(`${id}`);
}
print_id::<UserId>(42);

8. Standard Library From Impls

The standard library provides From impls for common lossless conversions:

Numeric widenings (infallible, lossless):

From To
i8 i16, i32, i64
i16 i32, i64
i32 i64
u8 u16, u32, u64, i16, i32, i64
u16 u32, u64, i32, i64
u32 u64, i64
f32 f64

Other conversions:

TryFrom impls for narrowing conversions:

9. Interaction with Existing Features

Feature Status Interaction
as casts Unchanged as = bit cast; From = semantic conversion
Literal coercion Unchanged Literals still coerce to target type without From
null → Option::None Unchanged Syntactic sugar, not a From call
Template strings Unchanged \{x}`usesDisplay, notFrom`
Newtype as Unchanged as still works; From is additionally auto-generated

Consequences

Benefits

Trade-offs

Future Considerations

into T Operator

If the community finds Type::from(value) too verbose in practice, an into T postfix operator could be added:

let s = 42 into String;          // equivalent to String::from(42)
let id = raw_id into UserId;     // equivalent to UserId::from(raw_id)

This preserves target type visibility (unlike .into()) while being more concise than Type::from(value). It is explicitly out of scope for the initial release — we start with From::from() and evaluate community feedback.

from T Parameter Modifier

A parameter modifier that performs conversion at the call site without monomorphization:

fn greet(name: String from &str) with Stdout {
    println(`Hello, ${name}!`);
}
greet("world");  // compiler inserts String::from("world") at call site

This is reserved as a future possibility but not included in this proposal.

References