Skip to main content
v0.13.0
rfc-0019

Return Context Type Propagation

Summary

Fix a bug in typechecker Pass 2 where return and break statements do not propagate the enclosing function's return type as the expected type for the return value. This causes type inference to fail on any under-constrained expression in return position — most visibly return nope; and return Result::Err { ... }; — even when the function's return type annotation fully determines the missing type.

:

Motivation

The spec states that nope's type must be "determinable from context" and shows return Result::Err { ... } as valid code in a function annotated -> Result<Float, String>. Both currently fail with E0002 at the typechecker's Pass 2 stage.

fun find(arr: Int[], target: Int) -> Perhaps<Int> {
return nope; // E0002 — cannot infer type of `nope`
}

fun divide(a: Float, b: Float) -> Result<Float, String> {
return Result::Err { error = "division by zero" }; // E0002 — cannot infer type
return Result::Ok { value = a / b }; // fine — T resolved from field
}

The same failure occurs with break nope; and break Result::Err { ... }; inside a loop.

The same expressions work correctly as tail expressions (last line of a function body without return) because construct_block already receives the function's return type and propagates it. Only the explicit return and break paths are broken.

Root Cause

Pass 2 (construction.rs) builds a ConstructCtx that has no field tracking the current function's return type. construct_stmt therefore passes None as expected_ty when constructing the return value:

Stmt::Return(r) => {
let value = match &r.value {
Some(e) => Some(construct_expr(e, None, ctx)?), // <-- None
None => None,
};

construct_expr(nope, None) reaches construct_literal_type(Nope, None, span) which requires expected_ty and fails. construct_enum_literal_ty for a fieldless or partially-constrained variant (e.g. Result::Err where T is absent from the variant's fields) also requires expected_ty to fill in the unresolved type parameter.

Pass 1 (inference.rs) does not have this bug — InferContext has current_return_type and construct_stmt in inference correctly adds a constraint between the return value's type and the current return type.

:

Proposal

Pass 2 — ConstructCtx

Add a current_return_ty field and push/pop methods mirroring what InferContext already does:

struct ConstructCtx<'a> {
// ... existing fields ...
current_return_ty: Option<Type>,
}

impl<'a> ConstructCtx<'a> {
fn push_return_type(&mut self, ty: Option<Type>) -> Option<Type> {
std::mem::replace(&mut self.current_return_ty, ty)
}
fn pop_return_type(&mut self, saved: Option<Type>) {
self.current_return_ty = saved;
}
}

construct_fun_decl and construct_method_decl

Push the resolved return type before constructing the body, pop after:

let saved = ctx.push_return_type(ret_ty.clone());
let typed_block = construct_block(&fun.body, ret_ty.as_ref(), ctx)?;
ctx.pop_return_type(saved);

construct_stmt

For Return and Break, pass ctx.current_return_ty.as_ref() as expected_ty:

Stmt::Return(r) => {
let value = match &r.value {
Some(e) => Some(construct_expr(e, ctx.current_return_ty.as_ref(), ctx)?),
None => None,
};

For Break, the expected type is the loop's break type, not the return type. A current_break_ty field follows the same pattern and is pushed when entering a loop body and popped on exit. Since break with a value only occurs inside loop, this is a separate stack entry.

No spec changes required

The spec already correctly describes this behaviour. The fix makes the implementation match the spec.

Alternatives Considered

Store a type variable per nope literal in the AST (annotate during Pass 1) and resolve it in Pass 2 via the substitution. This would avoid the need for expected_ty entirely for nope. It would require the AST to carry inference metadata (type variable IDs), which is a larger structural change and inconsistent with the current two-pass design where the AST is untyped. The expected_ty propagation approach is consistent with how empty arrays ([]) and other under-constrained literals are already handled.

:

Open Questions

  • Should construct_stmt(Break) use a dedicated current_break_ty (pushed when entering loop), or should it fall back to current_return_ty? Using a dedicated stack is cleaner and mirrors the inference pass design. There is no case where a break value should inherit the function's return type — they are independent.

Timing Recommendation

Small, isolated fix with no design risk. No new language surface. Implement before any v0.2 work that exercises Perhaps<T> or Result<T, E> in function return position.

:

References

  • Language spec: docs/public/reference/spec/types.mdPerhaps<T>, § Result<T, E>)
  • metel-interpreter/src/typechecker/construction.rs
  • metel-interpreter/src/typechecker/inference.rsInferContext::current_return_type for reference

Decision

Outcome: Accepted Target: v0.2

Pure bug fix — no design question, implementation matches existing Pass 1 pattern exactly.

Coverage Checklist (added 2026-08-19, not part of the original RFC)

Retroactive breakdown of this RFC's distinct, fixture-testable normative claims, as headed sections for citation purposes only. The document above is unchanged and remains the historical record. Deliberately excludes claims that aren't independently observable from a program's behavior -- implementation strategy, design rationale, or internal architecture discussion belongs in the RFC's own prose, not here.

1. Return values receive the enclosing function's expected type

An expression in return position is typechecked against the enclosing function or method's declared return type. This permits otherwise under-constrained return values when that annotation determines their type.

2. Break values receive the enclosing loop's expected type

An expression in break position is typechecked against the value type of its enclosing loop, independently of the enclosing function's return type.