RFC 041: First-Class Rust Interop Authoring¶
- Status: Draft
- Created: 2026-03-09
- Author(s): Danny Meijer (@dannymeijer)
- Related:
- RFC 005 (Rust interop)
- RFC 013 (Rust crate dependencies)
- RFC 023 (Compilable stdlib & Rust module binding)
- RFC 026 (User-defined trait bridges)
- RFC 039 (
racefor awaitable concurrency)
- Issue: —
- RFC PR: —
- Target version: v0.2
Summary¶
This RFC's central claim is simple: once a rust::... import resolves, the imported Rust item should behave like an ordinary Incan symbol of the corresponding kind. Rust provenance and lowering details remain compiler-managed, but authors should not have to reconstruct that information in shim code or handwritten bridge ceremony.
That core claim is made concrete through four mechanisms:
- ordinary member and associated-item lookup, including rebinding, for Rust-origin APIs
- compiler-managed boundary adaptation for built-in Incan types through per-type coercion matrices
- explicit
rusttypeinterop roots for direct Rust-backed non-builtin types, with ordinarynewtypewrappers above them - Incan-authored capability bounds that lower to Rust backend requirements
rust:: remains the explicit dependency-resolution and declaration boundary. Async semantics remain Incan-owned and are specified elsewhere; this RFC only requires Rust-origin async APIs to plug into that eventual model.
Core model¶
Read this RFC as one foundation plus four mechanisms:
- Foundation: after
rust::...import resolution succeeds, imported Rust items become first-class compiler symbols. - Mechanism: Rust methods and associated items participate in ordinary Incan lookup and rebinding.
- Mechanism: built-in Incan types cross explicit Rust boundaries through compiler-owned coercion matrices with canonical lowerings, admitted target types, and per-target policies.
- Mechanism: direct Rust-backed non-builtin types use
rusttypeas the interop-root declaration form, whilenewtyperemains the ordinary wrapper syntax above that root. - Mechanism: Incan-written capability bounds lower to Rust predicates without exposing raw Rust syntax.
Everything else in the RFC follows from that model: optional wrappers, interop metadata placement, diagnostics, and the reduced need for handwritten Rust adapter layers.
Motivation¶
Today Rust interop is explicit, but still awkward to author against¶
RFC 005 gave Incan a clear and explicit Rust import surface:
from rust::serde_json import from_str
from rust::std::time import Instant
That solved an important problem: users can reach Rust crates without pretending Rust is part of Incan's standard library. However, the current model still treats Rust interop more like a boundary crossing than like ordinary language authoring.
Put differently, too much of the current Rust interop still comes with unnecessary "bridge ceremony": adapter rituals authors perform only because imported Rust symbols are not yet first-class enough, even when the compiler already has enough information to model the relationship.
Pain points today:
- imported Rust functions are much easier to use than imported Rust types with rich method surfaces
- imported Rust types often need handwritten Rust aliases or shims before they become pleasant to use from Incan
- stdlib and user libraries often end up writing a parallel adapter layer in Rust just to expose constructors, methods, associated helpers, or capability bounds in an Incan-shaped API
- wrapper types become mandatory even when the user only wants to use a Rust type directly
This is exactly the awkward boundary that showed up during the RFC 023 stdlib migration. The public std.async surface now lives in .incn, but several modules still depend on Rust adapter files purely because Rust items are not yet first-class enough inside Incan's own authoring model.
The real problem is not the runtime substrate¶
This RFC is not about replacing Tokio, replacing Rust, or pretending that async runtimes, operating system access, or framework integrations are language-internal.
The real problem is narrower:
- the runtime substrate can remain Rust
- the dependency boundary can remain explicit through
rust:: - but library authors should not have to keep falling back to handwritten Rust just to make imported Rust APIs usable in Incan
Rust and Incan both lower into the same backend world¶
After lowering, imported Rust items and Incan-authored items both live in the same generated Rust program. That means the language should exploit that fact where it can:
- imported Rust types should be usable as types
- imported Rust methods should be reachable through ordinary member lookup
- imported Rust constructors and associated items should be reachable through ordinary associated-item lookup
- Rust-facing capability bounds should be expressible in Incan syntax and lowered to Rust predicates
The goal is not to erase the fact that something came from Rust. The goal is to stop making users manually reconstruct that fact in boilerplate the compiler could track itself.
Goals¶
- Retain the explicit
rust::prefix as the dependency-resolution boundary for Rust imports. - Make imported Rust types, functions, constants, and modules first-class symbols in Incan.
- Make imported Rust methods and associated items reachable through ordinary Incan lookup and rebinding rules.
- Make wrappers optional for API design, not mandatory for interop.
- Introduce a compiler-managed interop coercion matrix for built-in Incan types at explicit Rust boundaries.
- Introduce
rusttypefor direct Rust-backed interop roots and let those declarations carry only the extra compiler-relevant interop edges that are not implied by the direct backing relation. - Allow Incan-authored generic bounds to lower to Rust constraints such as
Send,'static, and callable traits. - Keep async semantics Incan-owned while still allowing Rust runtimes and Rust futures to plug into that model.
Non-Goals¶
- Removing the
rust::prefix. This RFC keepsrust::as the explicit Rust dependency and import boundary. - Making Rust dependencies implicit or guessing whether an import is Rust-backed.
- Replacing RFC 023's
rust.module()mechanism.rust.module()remains valuable for Incan-authored module surfaces backed by Rust implementations. - Replacing RFC 026's wrapper/newtype trait preservation story. This RFC makes wrappers optional; RFC 026 remains the place to preserve trait behavior when wrappers are chosen.
- Introducing arbitrary semantic conversions or unlimited implicit coercions between Incan values and foreign Rust types.
- Requiring authors to restate Rust backing information already implied by
type X = rusttype Y. - Defining a separate out-of-band registry for non-builtin interop metadata instead of attaching it to the Incan type declaration.
- Defining Incan's async semantics. Async remains core language territory; RFC 039 and related work define that model.
- Promising that every Rust language feature or crate API will become directly expressible on day one.
Guide-level explanation (how users think about it)¶
The guide-level sections below follow the same reading order as the core model above: first-class imports, ordinary Rust member use, built-in boundary adaptation, rusttype interop roots, and capability bounds.
Ordinary use: import a Rust type and use it directly¶
The simplest rule is the most important one: if you import a Rust item, it should behave like the corresponding kind of Incan symbol.
from rust::regex import Regex
pattern = Regex.new("^user_[0-9]+$")
ok = pattern.is_match("user_42")
No shim file should be required just to make Regex.new(...) or pattern.is_match(...) visible in Incan.
Optional wrapper: curate docs and naming when you want to¶
Wrappers still matter, but they should be optional:
from rust::regex import Regex as RustRegex
pub type Regex = rusttype RustRegex:
"""
A docs-rich Incan wrapper around `rust::regex::Regex`.
"""
matches = is_match
This wrapper exists because the author wants:
- custom docs
- an Incan-first name
- possibly extra convenience methods
It should not exist because the interop model forces it.
Method rebinding should feel like ordinary Incan¶
If a wrapper wants to expose a Rust method under a different name or keep an internal alias, it should use ordinary Incan syntax:
from rust::tokio::sync::mpsc import Sender as RustSender
pub type Sender[T] = rusttype RustSender[T]:
__host_send = RustSender.send
send_now = try_send
The compiler should understand that both aliases refer to Rust members. Users should not need a special interop-only decorator or method-binding mini-language for common cases.
A rusttype is also a Rust-backed API surface¶
rusttype is not only a boundary declaration. It also says that the declared Incan type exposes the backing Rust API surface through ordinary Incan lookup:
from rust::regex import Regex as RustRegex
type Regex = rusttype RustRegex
pattern = Regex.new("^user_[0-9]+$")
ok = pattern.is_match("user_42")
In other words, Regex.new(...) and pattern.is_match(...) work because Regex is a Rust-backed API surface, not because the author manually rebound those members one by one. Rebinding remains available when the author wants to curate or rename that surface, but ordinary access should work by default.
Capability bounds should look like Incan, not raw Rust¶
Library authors must be able to express Rust-lowered constraints in Incan source:
from std.rust import Send, Static, FnOnce
pub def spawn_blocking[T with Send + Static, F with FnOnce[T] + Send + Static](task: F) -> T:
...
The user writes Incan-facing capability bounds. The compiler lowers them to the appropriate Rust predicates.
Built-in values cross a compiler-owned coercion matrix at Rust boundaries¶
Built-in Incan types should also cross explicit Rust boundaries without pushing conversion ceremony into user code:
from rust::my_crate import takes_f32
takes_f32(1.0)
If the Rust boundary expects a target type such as f32, the compiler may insert the appropriate host-side coercion from the built-in type's canonical Rust lowering. The user does not write .into() or as casts in Incan code for these compiler-managed interop coercions.
The important design claim is that this is not an ad hoc bag of call-site exceptions. Each built-in type owns a compiler-managed interop coercion matrix consisting of:
- its canonical Rust lowering
- the Rust boundary target types the language admits for that built-in
- a per-target policy such as exact, lossless, sanctioned lossy, or reject
The compiler consults that matrix only when crossing an explicit Rust boundary. The initial matrix for this RFC is:
| Incan built-in | Canonical Rust lowering | Admitted Rust boundary targets | Initial policy |
|---|---|---|---|
int |
i64 |
i64 |
exact only |
float |
f64 |
f64, f32 |
exact to f64; sanctioned lossy to f32 |
bool |
bool |
bool |
exact only |
str |
String |
String, &str |
exact to String; borrow to &str |
bytes |
Vec<u8> |
Vec<u8>, &[u8] |
exact to Vec<u8>; borrow to &[u8] |
None / unit |
() |
() |
exact only |
Structural built-ins follow the same idea recursively:
| Incan built-in | Canonical Rust lowering | Admitted Rust boundary targets | Initial policy |
|---|---|---|---|
Option[T] |
Option[T_rust] |
Option[U] when T -> U is admitted |
recursive slot-wise adaptation only |
Result[T, E] |
Result[T_rust, E_rust] |
Result[U, F] when T -> U and E -> F are admitted |
recursive slot-wise adaptation only |
Tuple[A, B, ...] |
(A_rust, B_rust, ...) |
same-arity tuple when each position's adaptation is admitted | recursive positional adaptation only |
List[T] |
Vec[T_rust] |
Vec[U] when T -> U is admitted |
recursive element-wise adaptation only |
Dict[K, V] |
std::collections::HashMap<K_rust, V_rust> |
HashMap[K2, V2] when key/value adaptations are admitted |
recursive key/value adaptation only |
Set[T] |
std::collections::HashSet<T_rust> |
HashSet[U] when T -> U is admitted |
recursive element-wise adaptation only |
FrozenList[T] |
Vec[T_rust] |
Vec[U] when T -> U is admitted |
same as List[T]; immutable at the Incan API level |
FrozenDict[K, V] |
std::collections::HashMap<K_rust, V_rust> |
HashMap[K2, V2] when key/value adaptations are admitted |
same as Dict[K, V]; immutable at the Incan API level |
FrozenSet[T] |
std::collections::HashSet<T_rust> |
HashSet[U] when T -> U is admitted |
same as Set[T]; immutable at the Incan API level |
Notes:
intis intentionally conservative in the initial matrix; other integer widths should be written explicitly via RFC 009-sized types rather than reached through implicitintcoercion- const-only
FrozenStrandFrozenBytesfollow the same Rust-boundary matrix entries asstrandbytes - structural built-ins recurse through the matrices of their component types, but do not implicitly change container kind in the initial matrix
- this table is intentionally conservative: built-ins do not implicitly adapt to semantic host types just because some Rust API happens to accept them
- future sized numeric types from RFC 009 are exact-lowering types in their own right; this RFC does not need
int -> i32orint -> u16style implicit coercions to make those usable
That means the matrix is bounded interop, not arbitrary semantic conversion:
from rust::my_crate import takes_f32, takes_duration
takes_f32(1.0) # allowed when `float -> f32` is admitted by the built-in matrix
takes_duration(1.0) # not a built-in coercion; needs an explicit adapter or `rusttype`
The compiler may adapt built-in values to admitted Rust boundary targets in ways the language defines as meaningful; it should not silently guess conversions to unrelated domain types.
rusttype marks the Rust boundary once¶
For host-backed non-builtin types, rusttype is the direct Rust-backed declaration form:
from rust::std::collections import HashMap as RustHashMap
type Counter[T] = rusttype RustHashMap[T, usize]:
def total(self) -> int:
...
The compiler should infer the exact wrap/unwrap relation from that declaration alone. Authors should not need to restate the same canonical backing in a separate interop block just to make exact Rust-boundary adaptation work.
An interop block exists only for extra edges that are not already implied by the rusttype itself:
from rust::mail import EmailAddress as RustEmailAddress
type Email = rusttype RustEmailAddress:
def parse(raw: str) -> Email:
...
interop:
from str try Email.parse
type WorkEmail = newtype Email
type PersonalEmail = newtype Email
Here, Email is the interop root because it is a rusttype: it directly wraps the Rust type. The fallible str -> Email rule is declared once on that root, and the qualified form makes the adapter source explicit: the compiler is using Email.parse. The important mental model is that interop: points at an ordinary callable on the Email type surface; it does not create that callable. A short form such as from str try parse is equivalent when the name resolves unambiguously on the Email surface. Domain wrappers above it use ordinary newtype syntax, inherit the representation chain, and can rely on the same root-defined interop behavior when the expected target type makes the path unambiguous.
Async stays core Incan¶
Even when using Rust-backed runtimes or futures, Incan still owns async semantics:
from rust::tokio::task import yield_now
async def pause() -> None:
await yield_now()
The point is not that Tokio defines async for Incan. The point is that imported Rust async items should plug into Incan's async model cleanly once that model is fully specified. Type coercion at Rust boundaries is a separate concern handled by the compiler-managed interop coercion rules for built-in types.
Reference-level explanation (precise rules)¶
Import syntax¶
This RFC does not change the syntactic shape introduced by RFC 005:
import rust::CRATE[::PATH...] [as ALIAS]
from rust::CRATE[::PATH...] import ITEM[, ITEM2 ...]
rust:: remains mandatory for Rust imports.
Symbol classification¶
When a rust::... import resolves successfully, the imported item is recorded by the compiler as a Rust-origin symbol with:
- its canonical Rust path
- its item kind
- its type-level metadata
- its value-level metadata
- its associated items and methods, when applicable
The compiler must classify imported Rust items into language-relevant symbol categories such as:
- type
- function
- constant
- module
This classification is compiler-managed provenance, not user-authored scaffolding.
First-class item behavior¶
Imported Rust items behave like their corresponding Incan-level kinds:
- an imported Rust type is valid in type positions
- an imported Rust function is valid in call/value positions
- an imported Rust constant is valid in value/constant positions
- an imported Rust module participates in module/member resolution
The important rule is that the origin of a symbol being Rust must not make it second-class after import resolution succeeds.
Member and associated-item resolution¶
For imported Rust types, the compiler must support member and associated-item resolution using Rust metadata.
This means:
TypeName.associated_item(...)resolves against Rust associated itemsvalue.method(...)resolves against Rust methods- wrappers and
rusttypes may rebind imported Rust members through ordinary Incan aliasing rules
Inside a rusttype body over a Rust type, the design target is that imported Rust members are available for rebinding through normal syntax:
from rust::tokio::sync::mpsc import Sender as RustSender
pub type Sender[T] = rusttype RustSender[T]:
send_now = try_send
__host_send = RustSender.send
The exact scoping rule is specified as follows:
- if a
rusttypebody is built over an imported Rust type, members visible on the backing type are in scope for alias declarations - fully qualified rebinding through
BackingType.memberis always valid - short-form rebinding through
memberis valid when the name resolves unambiguously to a member of the backing type
Optional wrappers and newtypes¶
Wrapping a Rust type in an Incan type is always optional unless the author wants one of these:
- a curated public API
- custom docs
- convenience helpers
- constrained visibility
- additional invariants
- trait/delegation behavior via RFC 026
The compiler must not require a wrapper merely to make an imported Rust type usable.
When an author does choose an Incan type over a Rust item, the direct Rust-backed declaration form is rusttype. Ordinary newtype remains available for wrappers over existing Incan types, including wrappers over a rusttype. Only when the author wants the compiler to understand more than "this directly wraps that Rust backing type" should an interop declaration surface come into play, and that surface belongs on the rusttype declaration itself. In other words, the authoritative place to declare non-builtin host interop metadata is the Incan interop-root declaration, not a side registry and not Rust-only glue.
Compiler-managed interop coercions for built-in types¶
First-class Rust symbol resolution is only half of the interop story. Built-in Incan values must also be able to cross explicit Rust boundaries in a principled way.
For every built-in Incan type, the compiler maintains:
- a canonical Rust lowering
- a set of admitted Rust boundary target types
- a per-target coercion policy
This coercion matrix is compiler-owned and conceptually lives with the built-in type definitions themselves, rather than as scattered ad hoc call-site hacks. In other words, the design direction is closer to built-in type metadata in the design space of Float.to_rust_f32(), Float.to_rust_f64(), Int.to_rust_u32(), and similar target-specific lowering hooks than to a bag of unrelated emitter heuristics. These are compiler-managed lowering hooks, not user-callable methods.
The initial built-in matrix for this RFC is:
| Incan built-in | Canonical Rust lowering | Admitted Rust boundary targets | Initial policy |
|---|---|---|---|
int |
i64 |
i64 |
exact only |
float |
f64 |
f64, f32 |
exact to f64; sanctioned lossy to f32 |
bool |
bool |
bool |
exact only |
str |
String |
String, &str |
exact to String; borrow to &str |
bytes |
Vec<u8> |
Vec<u8>, &[u8] |
exact to Vec<u8>; borrow to &[u8] |
None / unit |
() |
() |
exact only |
Structural built-ins follow recursive structural rules rather than one row per concrete instantiation:
| Incan built-in | Canonical Rust lowering | Admitted Rust boundary targets | Initial policy |
|---|---|---|---|
Option[T] |
Option<T_rust> |
Option<U> when T -> U is admitted |
recursive slot-wise adaptation only |
Result[T, E] |
Result<T_rust, E_rust> |
Result<U, F> when T -> U and E -> F are admitted |
recursive slot-wise adaptation only |
Tuple[A, B, ...] |
(A_rust, B_rust, ...) |
same-arity tuple when each position's adaptation is admitted | recursive positional adaptation only |
List[T] |
Vec<T_rust> |
Vec<U> when T -> U is admitted |
recursive element-wise adaptation only |
Dict[K, V] |
std::collections::HashMap<K_rust, V_rust> |
HashMap<K2, V2> when key/value adaptations are admitted |
recursive key/value adaptation only |
Set[T] |
std::collections::HashSet<T_rust> |
HashSet<U> when T -> U is admitted |
recursive element-wise adaptation only |
FrozenList[T] |
Vec<T_rust> |
Vec<U> when T -> U is admitted |
same as List[T]; immutable at the Incan API level |
FrozenDict[K, V] |
std::collections::HashMap<K_rust, V_rust> |
HashMap<K2, V2> when key/value adaptations are admitted |
same as Dict[K, V]; immutable at the Incan API level |
FrozenSet[T] |
std::collections::HashSet<T_rust> |
HashSet<U> when T -> U is admitted |
same as Set[T]; immutable at the Incan API level |
int is intentionally conservative in the initial matrix; other integer widths should be written explicitly via RFC 009-sized types rather than reached through implicit int coercion. Const-only FrozenStr and FrozenBytes follow the same Rust-boundary entries as str and bytes. Structural built-ins recurse through the matrices of their component types, but do not implicitly change container kind in the initial matrix. Future sized numeric types from RFC 009 are exact-lowering types in their own right; this RFC therefore does not rely on implicit int -> i32 or int -> u16 style coercions in the initial matrix.
The rules are:
- exact canonical lowering match wins first
- if there is no exact match, the compiler may apply a compiler-known lossless coercion for that built-in type
- if no lossless coercion exists, the compiler may apply a compiler-sanctioned lossy coercion for that built-in type only when the target pair is explicitly admitted by the language
- if the Rust target type is not one of the compiler's known admitted targets, the compiler may attempt a single-step fallback conversion from the canonical lowered Rust type only when that fallback is compiler-approved and unambiguous
- coercions do not chain arbitrarily
- fallible conversions are not inserted implicitly
- semantic conversions to unrelated domain types are out of scope
Examples of the distinction:
- adapting an Incan
floatto an admitted Rust float width is in scope when the coercion rule is part of the built-in type's matrix - adapting an Incan
strto a Rust string-facing boundary type may be in scope when the compiler already owns that interop rule - adapting an Incan numeric value to a semantic host type such as
Durationis not a built-in implicit coercion; it requires an explicitrusttypeinterop root or adapter API
Diagnostics must reflect which coercion step failed and what the user can do next. When a built-in value cannot be adapted to the requested Rust boundary type, the compiler should suggest one of:
- use a different Rust API overload or boundary type
- introduce an explicit adapter/helper
- wrap the host type in an Incan-facing abstraction
Interop metadata on rusttype definitions¶
Built-in types are not the only kinds of types that need compiler-understood interop behavior. Stdlib and user-authored host-backed Incan types, such as the std.collections shapes described in RFC 030, may also need to describe how they map onto Rust.
The key design rule is:
- for built-ins, interop metadata is compiler-intrinsic
- for non-built-in host-backed types, direct Rust-backed interop roots use
type ... = rusttype ..., and extra interop metadata is declared there
This keeps the interop story Incan-authored even when the lowering target is Rust. It also lets stdlib and third-party library authors participate in the same system rather than forcing them back into Rust-only bridge code.
Normative rules:
type X = rusttype Ydeclares the direct representation relation betweenXand the Rust typeY- exact Rust-boundary wrap/unwrap behavior implied by that declaration must not require restating
Yin aninteropblock - extra boundary edges such as parsing, serialization, or other declared adapters belong on that
rusttypedeclaration type Z = newtype Xwraps an existing Incan type and does not itself become a new Rust interop rootnewtypewrappers over arusttypeinherit the representation chain and may rely on root-defined edges when the expected target type makes the path unambiguous- when the target is a raw Rust type, adaptation should resolve through the nearest matching
rusttyperoot rather than searching arbitrary wrapper chains
Rust-backed API surface on rusttype¶
type X = rusttype Y has two effects at once:
- It establishes
Xas the interop root for the Rust backingY. - It establishes
Xas an Incan-visible API surface over the Rust members and associated items ofY.
Normative rules:
X.method(...)andvalue.method(...)resolve against members of the backing Rust type using ordinary lookup rules- Rust associated items visible on
Yare available throughX.associated_item(...) - aliases declared in the
rusttypebody may rename or curate that Rust-backed API surface interop:does not define ordinary methods; it defines boundary adaptation edges
This distinction matters: the Rust-backed API surface answers "what members can I call on this type?", while interop: answers "how may values cross into or out of this type at Rust boundaries?"
Full interop: specification¶
The interop: block is a required keyword when declaring non-obvious boundary edges on a rusttype. Its syntax is:
type Name[Params...] = rusttype RustBacking[Params...]:
interop:
from SourceType via adapter_ref
from SourceType try adapter_ref
into TargetType via adapter_ref
into TargetType try adapter_ref
Normative syntax rules:
interop:may appear at most once on arusttypedeclaration- each line inside the block declares exactly one directed adaptation edge
from S ...declares an edge from Incan typeSinto the declaringrusttypeinto T ...declares an edge from the declaringrusttypeinto Incan typeTSourceTypeandTargetTypeuse ordinary Incan type-expression syntax- union types from RFC 029 are therefore valid in
SourceTypeandTargetType via refdeclares an infallible adaptertry refdeclares a fallible adapteradapter_refmay be written either as a short-form name such asparseor as a qualified callable reference such asEmail.parseinterop:is only valid onrusttypedeclarations, not on ordinarynewtypewrappersinterop:references an existing callable; it does not itself declare a new method or function
Normative semantic rules:
- the direct backing relation implied by
type X = rusttype Yis not spelled insideinterop: - adapter references resolve against the declaring
rusttype's API surface - short-form
parseinsidetype Email = rusttype RustEmailAddressis read asEmail.parse - fully qualified callable references such as
Email.parseare always valid - if a short-form adapter name is ambiguous, the compiler must reject it and require a qualified reference
- adapter reference lookup is ordinary callable lookup on the declaring
rusttypesurface; this RFC does not introduce a separate overload-resolution system for adapters from S via fmeans the compiler may adapt anSinto the declaringrusttypeby callingffrom S try fmeans the same adaptation is permitted, but may fail and therefore carries the failure behavior of that adapterinto T via fmeans the compiler may adapt the declaringrusttypeintoTby callingfinto T try fmeans the same adaptation is permitted, but may fail- semantically,
from S ...reads as "use this callable to build the declaringrusttypefromS", whileinto T ...reads as "use this callable to project the declaringrusttypeintoT" from int | float try fdeclares one union-typed edge whose admitted source domain isint | float; it is not sugar for two separate edges- likewise,
into A | B via fdeclares one edge whose target is the unionA | B - multiple declared edges must not be chained together arbitrarily
- at most one declared interop edge may participate in a single adaptation path, optionally alongside the implied wrap/unwrap steps of the
rusttypechain - if multiple adapter paths are valid and the expected target type does not disambiguate them, the compiler must reject the adaptation as ambiguous
Conceptually, a host-backed rusttype should be able to declare compiler-relevant information in the design space of:
- admitted Rust boundary targets
- coercion hooks or policies
- fallible adapter edges such as parse/serialize/deserialize
- compiler-relevant structural capabilities
Illustrative direction:
type Email = rusttype RustEmailAddress:
def parse(raw: str) -> Email:
...
interop:
from str try Email.parse
type WorkEmail = newtype Email
type PersonalEmail = newtype Email
This example intentionally does not restate a rust canonical = RustEmailAddress (or something similar) line, because the rusttype declaration already says that. The interop block only adds the non-obvious str -> Email edge, and the qualified form makes it explicit that the compiler is using the already-declared Email.parse callable.
Additional illustrative direction:
type Json[T] = rusttype RustJsonValue:
interop:
from T try Json.serialize
into T try Json.deserialize
type Duration = rusttype RustDuration:
interop:
from int via milliseconds
from float via seconds
type SomeExample = rusttype SomeRepresentativeRustType:
def from_number(value: int | float) -> SomeExample:
...
interop:
from int | float try SomeExample.from_number
Read these examples as references to callables on the declaring rusttype surface: from T try Json.serialize means "adapt a T into Json[T] using Json.serialize", while into T try Json.deserialize means "adapt a Json[T] into T using Json.deserialize". Likewise, from int | float try SomeExample.from_number means "adapt any value assignable to int | float into SomeExample using one union-typed adapter edge". In all of these examples, the attachment point and ownership model are normative: this metadata belongs to the Incan-side rusttype declaration, not to a disconnected compiler registry.
This is orthogonal to RFC 025. RFC 025 governs compile-time dispatch among same-name trait methods; interop: adapter refs are just ordinary callable references on the rusttype surface. In other words, interop: does not introduce general overloading. It simply points at a callable that the language already knows how to resolve.
This also composes directly with RFC 029. Union types describe the shape of the adapter's accepted input or produced output; interop: still describes boundary conversion. A union-typed adapter edge is therefore one conversion rule over a union-shaped type, not an overloaded family of separate rules.
This is especially important for types like RFC 030's Deque[T] and Counter[T]. They are not compiler built-ins, but they are also not "just some Rust type." They are Incan types with public Incan APIs, docs, and semantics, and the compiler should be able to understand their lowering and boundary behavior from Incan-authored declarations.
Rust-lowered capability bounds¶
This RFC introduces the concept of Rust-lowered capability bounds in Incan syntax.
These are Incan-facing bound names that lower to Rust predicates at code generation time. Examples include capability markers in the design space of:
SendSyncStaticFn[T]FnMut[T]FnOnce[T]
The exact initial set remains an implementation and design detail, but the semantics are normative:
- these bounds are written in Incan source
- they participate in generic
withclauses - they are checked and carried through the frontend as semantic constraints
- lowering emits the corresponding Rust predicates
These are not ordinary runtime traits in the same sense as user-authored Incan traits. They are compiler-recognized capability bounds whose purpose is to express Rust backend requirements in an Incan-shaped contract.
Async interaction¶
This RFC does not define async semantics.
However, it requires that imported Rust async items be able to participate in Incan's async model once that model is specified. In practice this means:
- imported Rust futures or future-producing functions must be mappable into Incan's awaitability rules
- wrapper authors must not need handwritten Rust shims merely to make ordinary Rust async APIs available to Incan async code
RFC 039 remains the place to define Incan's awaitable semantics and composition model.
Dependency resolution¶
rust:: imports remain tied to Rust dependency declarations as specified by RFC 013 and RFC 005.
Normative rules:
rust::std::...refers to Rust's standard library and does not create a Cargo dependency- any non-
stdcrate root in arust::...import must correspond to an allowed Rust dependency declaration path - unresolved Rust crate roots must produce a clear dependency-resolution diagnostic
This is the main reason the rust:: prefix remains desirable: it keeps the dependency boundary explicit and auditable.
Diagnostics¶
This RFC requires improved diagnostics for Rust-origin symbols. At minimum, the compiler should produce clear errors for:
- unknown Rust crates
- unknown Rust items in a resolved crate/module
- unknown Rust members or associated items
- unsupported Rust constructs not yet representable in Incan
- unsupported or ambiguous interop coercions at Rust boundaries
- missing or invalid interop metadata on a host-backed
rusttypedefinition - ambiguous wrapper-mediated adaptation paths that do not identify a unique interop root
- misuse of Rust-lowered capability bounds
- ambiguous short-form member rebinding inside wrappers
Diagnostics should make it clear when a symbol came from rust::... resolution and, where useful, include the canonical Rust path that failed.
Design details¶
Syntax¶
This RFC deliberately keeps syntax changes narrow:
rust::...import syntax stays as defined by RFC 005rusttypeis introduced as the direct Rust-backed declaration formnewtyperemains the ordinary wrapper syntax for Incan-to-Incan wrappingrusttypedeclarations may grow an optionalinterop:block for non-obvious boundary edges- rebinding should use ordinary aliasing syntax
- capability bounds use Incan
withclauses
The goal is to extend meaning, resolution, and lowering while making the interop root explicit in syntax, not to introduce a sprawling interop-specific mini-language.
Semantics¶
The semantic center of this RFC is:
rust::...remains the explicit Rust import boundary.- After import resolution succeeds, imported Rust items become first-class compiler symbols.
- Compiler-managed provenance tracks how those symbols lower to Rust.
- Member and associated-item lookup can resolve against Rust-origin metadata.
- Wrappers are optional and only exist when the author wants a better API surface.
- Built-in types own compiler-managed interop coercion matrices for explicit Rust boundaries.
type X = rusttype Ymarks a direct Rust-backed interop root and already implies the exact backing relation.newtypeover arusttyperemains an ordinary Incan wrapper that inherits the representation chain.- Optional interop metadata on
rusttypedeclarations declares only extra edges beyond that implied exact relation. - Capability bounds let Incan express Rust backend requirements without exposing raw Rust syntax.
Interaction with existing features¶
async/await¶
This RFC does not define async semantics. It only requires that Rust-origin async APIs become consumable within whatever async model Incan defines. RFC 039 remains the owner of Incan's awaitability semantics.
traits/derives¶
Imported Rust items becoming first-class does not eliminate the need for wrapper trait preservation. If an Incan rusttype or newtype wrapper must preserve or expose Rust trait behavior, RFC 026 remains relevant.
imports/modules¶
This RFC keeps rust:: as the only Rust import prefix. It extends what imported items can do after resolution rather than changing how the dependency boundary is spelled.
error handling¶
This RFC improves the ergonomics of wrapping and reusing Rust error/result surfaces, but it does not mandate automatic conversion of arbitrary Rust error types into Incan model types. Library authors may still wrap errors intentionally when they want a curated API.
Rust interop¶
This RFC is an extension of RFC 005, not a replacement. RFC 005 established explicit imports and core type mapping. RFC 041 makes the imported symbols themselves first-class enough to support native-feeling library authoring.
Compatibility / migration¶
This RFC is designed to be source-compatible where possible.
Existing code that uses:
rust::...imports- handwritten Rust shims
- explicit wrapper/newtype layers
continues to work.
What changes is that many of those shims and mandatory wrappers can gradually become unnecessary. Migration is opt-in:
- keep current shims if they still provide value
- remove them when direct Rust imports plus wrapper rebinding become sufficient
- mechanically migrate direct Rust-backed
newtypedeclarations torusttypewhere the implementation does not provide a temporary compatibility alias
Alternatives considered¶
Keep mandatory Rust shim layers¶
This is the status quo. It is workable, but it makes interop authoring feel heavier than it should and forces library authors to duplicate compiler-manageable information in Rust glue.
Drop the rust:: prefix and use plain imports¶
This was rejected for this RFC. The explicit rust:: prefix remains useful for dependency resolution, incan.toml declaration clarity, diagnostics, and keeping the Rust dependency boundary explicit.
Introduce an interop-specific decorator or binding language¶
This was rejected as the default direction. Ordinary use should rely on ordinary imports, lookup, and aliasing. Special interop-only syntax should be reserved for cases that genuinely cannot be expressed through the normal language model.
Reuse newtype for direct Rust-backed interop roots¶
This would work semantically, but it would hide an important distinction in the syntax: wrapping a Rust type directly is not the same authoring move as wrapping an existing Incan type. rusttype makes the interop root explicit, keeps newtype focused on ordinary wrappers, and gives the compiler and user a clearer shared model.
Always wrap imported Rust types in hidden compiler-generated wrappers¶
This would reduce some visible boilerplate, but it would keep the mental model indirect and would make interop harder to reason about. The better model is direct first-class imports, with explicit wrappers only when chosen.
Drawbacks¶
- The compiler becomes significantly more sophisticated in how it models imported Rust items.
- The compiler must own and maintain a coherent coercion matrix for built-in Incan types.
- Rust metadata loading and caching become central implementation concerns.
- Diagnostics must explain not only Incan semantics but also how Rust-origin items were resolved.
- The boundary between language semantics and backend semantics becomes more subtle, especially around capability bounds.
- Some Rust constructs will still remain out of scope, which means the compiler must be explicit about what “first-class” does and does not cover initially.
Implementation architecture (non-normative)¶
This RFC intentionally specifies the language model more strongly than the internal module layout, but the implementation should still keep interop policy central and boring. The core risk in a feature like this is not only complexity; it is drift, where the typechecker, lowering, emitter, runtime helpers, and tooling each grow their own partial idea of what an interop adaptation means.
The recommended shape is:
- shared pure interop policy lives in
incan_core - parser, typechecker, and adaptation planning live in compiler crates
- runtime-only helper glue lives in
incan_stdlib
In practice, that means builtin coercion matrices, adaptation recipe kinds, canonical names, and other pure interop metadata should sit beside the rest of the language's shared semantic policy rather than being redefined ad hoc in frontend and backend code. The compiler should then resolve rusttype declarations, validate interop: edges, and compute a single adaptation plan for a given Rust boundary crossing. Lowering and emission should consume that plan rather than each re-deciding conversion rules locally.
This is also why a separate dedicated interop crate is not the initial recommendation. incan_core already exists to hold pure shared semantics and registries, while incan_stdlib exists to hold runtime glue for generated programs. Adding another crate too early would likely scatter the model rather than simplify it. A separate crate only becomes attractive later if the interop machinery genuinely outgrows incan_core or needs to be consumed independently by tools beyond the compiler/runtime split described in the repository's layering docs.
Layers affected¶
- Parser / AST: new
rusttypedeclaration form with optionalinterop:block; short-form and qualified member rebinding syntax insiderusttypebodies. - Typechecker / Symbol resolution: Rust-origin provenance on imported items; first-class classification of imported Rust types, functions, constants, and modules; member and associated-item resolution against Rust metadata; built-in interop coercion matrix validation;
interop:edge validation and adapter reference resolution; Rust-lowered capability bounds inwithclauses. - IR Lowering: direct lowering of Rust-origin symbols without shim intermediaries; coercion insertion at explicit Rust boundaries from the compiler-owned built-in matrix; lowering of
rusttypewrap/unwrap steps from declared interop roots; emission of Rust predicates from capability bound annotations. - Emission: Rust member calls, associated-item calls, and interop coercion output driven by frontend-carried provenance rather than call-site heuristics.
- Stdlib / Runtime (
incan_stdlib): migration ofstd.asyncand similar modules away from handwritten Rust adapter layers where those layers existed solely for symbol exposure. - Formatter: stable formatting for
rusttypedeclarations,interop:blocks, and wrapper rebinding syntax. - LSP / Tooling: completions and docs for imported Rust members and associated items; improved diagnostics that surface the canonical Rust path in error messages.
Unresolved questions¶
- What exact metadata source should the compiler use for Rust items and members?
- Should the initial conservative built-in interop matrix expand beyond
float -> f32and borrow-based string/bytes targets once additional RFC 009 numeric types land? - Should direct Rust-backed
newtyperemain accepted as a compatibility alias forrusttypeduring migration? - In what contexts should fallible interop-root adapters be allowed to run implicitly, and how should their failures surface?
- What is the initial supported set of Rust-lowered capability bounds in
std.rust? - Should short-form rebinding like
send_now = try_sendbe allowed in every wrapper context, or only when unambiguous against one backing type? - How much of Rust associated-type and generic-method complexity should be in scope for the first implementation?
- How should privacy and visibility diagnostics be surfaced when a Rust item exists but should not be imported or called in the requested way?
- How should Rust-origin docs be surfaced in the LSP and docs tooling when a wrapper re-exports or rebinds a member?