RFC 121: Unified Incan/Rust type substrate¶
- Status: Planned
- Created: 2026-08-19
- Author(s): Danny Meijer (@dannymeijer)
- Related:
- RFC 005 (Rust interop)
- RFC 009 (sized integers)
- RFC 024 (extensible derive protocol)
- RFC 029 (union types)
- RFC 030 (
std.collections) - RFC 041 (first-class Rust interop authoring)
- RFC 043 (Rust trait implementation from Incan)
- RFC 054 (explicit call-site generics)
- RFC 091 (integer storage carriers)
- RFC 097 (Rust-hosted Incan caller)
- RFC 120 (canonical source symbol identity)
- RFC 128 (one callable trait over function types)
- RFC 129 (shared or copied bindings)
- RFC 141 (type matching;
std.reflection) - RFC 144 (trait fields and type-owned constants)
- RFC 164 (explicit ownership and borrowing)
-
975 (Oven: Cargo-free Incan/Rust toolchain)¶
-
1077 (unified Incan/Rust type substrate)¶
- Issue: https://github.com/encero-systems/incan/issues/1077
- RFC PR: —
- Written against: v0.5
- Shipped in: —
Summary¶
Incan and Rust are one type substrate, expressed through two surface syntaxes — not two independently-typed systems bridged by a conversion layer at every boundary. Every Incan type compiles to one real Rust type: List[str] is Vec<String>, int is i64 unless the type selects another carrier, and a model is a Rust struct. Crossing between Incan-authored and Rust-authored code, in either direction, passes the same value rather than converting it. This is the same relationship Kotlin has to Java: Kotlin's String is java.lang.String, not a wrapper around one, because both languages target the same underlying object model.
Motivation¶
Before Oven, this was not a real option. Rust was built by a separate toolchain (Cargo), so Incan's own type system had to be treated as independent — something that got translated to Rust as a backend implementation detail, at every boundary crossing, because there was no single build authority that could guarantee both sides stayed representation-compatible. Once Oven owns the full build graph for both Incan-authored and Rust-authored code in one project (RFC 119), that structural constraint disappears. There is no longer a reason to keep them as two systems needing a bridge.
A conversion framing has real costs. It implies generated adapter code per type, a check at each crossing, and a decision per signature about how a string or integer travels. It also leaves room for two libraries to produce two incompatible copies of the same standard type. If the two sides were never different representations, there is nothing to check or convert.
This claim affects several RFCs: RFC 097 for Rust calling Incan, and RFC 005, RFC 041 and RFC 043 for Incan calling Rust. This RFC states it once, in one place.
Goals¶
- State the principle: Incan and Rust share one type substrate.
- Give one rule for each category of type: built-ins, integer carriers, standard-library types, user-declared types, unions and traits.
- Give every standard-library type one definition, so values pass freely between libraries.
- Hold symmetrically for Rust calling Incan and Incan calling Rust.
- Keep what authors already have: exact-width integers, storage carriers, validated construction and RFC 041's admitted adaptations.
- Name every exception explicitly.
- Make a type's Rust representation something any tool or program can read.
Non-Goals¶
- This RFC does not change Incan's authoring syntax.
model,enum, andnewtypekeep meaning what they mean to an Incan author; this RFC is about what those declarations are in Rust. - This RFC does not define a new ABI, FFI boundary, C layout guarantee or serialization format. For identical types, there is no boundary left to define.
- This RFC does not change how operators behave.
//on anintstill rounds down. - This RFC does not remove validation. A rule a type declares still holds.
- This RFC does not edit RFC 005, RFC 041 or RFC 043, which are closed.
Guide-level explanation¶
Here is what an Incan author writes:
pub type Month = newtype int[ge=1, le=12, storage=u8]
pub model Reading:
pub month: Month
pub channel: int[storage=u8]
pub counts: List[int]
pub labels: Dict[str, str]
pub note: Option[str]
pub def summarize(reading: Reading) -> Result[str, str]:
...
And here is what those types are to Rust code in the same project:
pub struct Month(u8);
pub struct Reading {
pub month: Month,
pub channel: u8,
pub counts: Vec<i64>,
pub labels: HashMap<String, String>,
pub note: Option<String>,
}
Nothing is converted when a Reading crosses between the two. counts was never a different representation from Vec<i64>, so a Rust function can read it directly.
Integers follow their carrier. int is i64 by default, not always. RFC 091 lets a type select another carrier, and that carrier is its real Rust type. channel above is a u8, Month is a struct holding a u8, and type bigint = newtype int[storage=i128] is a struct holding an i128. Exact-width types from RFC 009, such as i8 and u128, are the Rust types of the same name.
Identity removes conversion, not checks. Every i64 is a valid int, so there is nothing to check when one crosses. A Month has a rule, so its Rust field is private and Rust code builds one only through its checked constructor.
Standard-library types have one definition. Suppose two libraries both use Duration in a public signature:
# policy/sources/incan/lib.incn
pub def evaluate_policy(request: ProviderRequest, window: Duration) -> Result[ProviderDecision, str]:
...
# billing/sources/incan/lib.incn
pub def charge_for(amount: int, window: Duration) -> Result[Receipt, str]:
...
Both signatures name the same Rust type, declared once by the standard-library Loaf that owns Duration, so a Rust caller can pass one library's value to the other. User-declared types need no such sharing, because each library owns its own ProviderRequest.
Borrowing has one spelling. str is String, and Borrowed[str] is what Rust calls &str. Likewise, Borrowed[list[T]] is a read-only view of existing elements, represented as Rust's &[T]. The elements may come from a list, an array or a slice; an author needs no different callback for each source:
def total(values: Borrowed[list[int]]) -> int:
return sum(values)
A Rust caller lending the middle two elements of [10, 20, 30, 40] to total gets 50, without allocating a list or copying the elements. The view preserves the source's lifetime and permits reading, indexing and iteration, not resizing or replacing the owning collection. An Incan author writes RFC 164's contracts, or lets RFC 129 infer them, and never needs a Rust spelling.
A trait is a real Rust trait. Adopting a trait in Incan produces a real implementation that Rust code can call and bound on, with named exceptions where Rust has no matching concept.
A representation can be read. A program can ask a type what it is in Rust, and each field names the type it belongs to:
def print_layout() -> None:
for field in Reading.__fields__():
println(f"{field.parent}.{field.name} is {field.type.representation}")
shop.readings.Reading.month is Month(u8)
shop.readings.Reading.channel is u8
shop.readings.Reading.counts is Vec<i64>
shop.readings.Reading.labels is HashMap<String, String>
shop.readings.Reading.note is Option<String>
Reference-level explanation¶
-
Every Incan type must compile to one real Rust type. Passing a value between Incan-authored and Rust-authored code must pass that value, with no generated wrapper, adapter or check at the crossing, except as rules 8, 9 and 13 allow.
-
Built-in types must have these Rust types:
Incan Rust int,i64i64float,f64f64boolboolstrStringbytesVec<u8>None()i8toi128,u8tou128,isize,usize,f32the Rust type of the same name Option[T]Option<T>Result[T, E]Result<T, E>Tuple[A, B, ...](A, B, ...)List[T]Vec<T>Dict[K, V]HashMap<K, V>Set[T]HashSet<T> -
An integer type with an RFC 091 carrier must be stored in that carrier. An unnamed
int[...]must compile to its carrier, and anewtypemust compile to a Rust struct holding its carrier. This RFC must not narrow what RFC 009 or RFC 091 allow an author to declare. -
Every other standard-library type, including
FrozenStr,FrozenBytes,FrozenList[T],FrozenDict[K, V], andFrozenSet[T], must have exactly one Rust definition, owned by the standard-library Loaf that declares it and identified by its RFC 120 canonical identity. Consumers must reference that definition and must not regenerate it. Two libraries using the same standard-library type must produce mutually compatible Rust values.A frozen value must be immutable, must copy in constant time without copying its contents, and must compare and hash by its contents. Its storage must be static for a constant and shared for a value produced while the program runs, such as one a decoder reconstructs or reflection returns. Shared storage must be safe to share between tasks and must be released when the last copy goes, so producing a frozen value at run time never leaks it.
-
A user-declared
modelorclassmust compile to a Rust struct, anenumto a Rust enum, and anewtypeto a Rust struct holding its underlying type. Incan's validation and derives (RFC 024) must attach to that same type, with no shadow representation. -
An anonymous union (RFC 029) must compile to one Rust enum per distinct set of member types, shared by every use of that set.
T | Nonemust beOption<T>. -
Identity must not remove a declared rule. Rust code must not be able to build a
newtype, or amodelwith a constrained field, in a way that breaks a declared rule, except by going through its checked constructor. -
Borrowed[T]must be a Rust shared reference toT, except for the borrowed-view mappings specified here, andBorrowedMut[T]an exclusive reference toT.Borrowed[str]must be&str,Borrowed[bytes]must be&[u8], andBorrowed[list[T]]must be&[T]. A borrowed list is a read-only view of existing elements, whether their source is a list, an array or a slice. Reading, indexing and iteration must work without copying elements or allocating a replacement list, including when Rust invokes an Incan callback. The view must retain its source's lifetime and must not imply ownership of, or access to, a growable list object. An operation requiring that owning object must not fabricate one from the view. This shared-view mapping does not grant exclusive access or resizing permission; exclusive contracts remain governed by RFC 164 and the inspected native signature. A plain parameter follows RFC 129 and RFC 164. Incan source must not need a Rust spelling for any of these. -
Where a Rust API asks for a different type than the value has, the adaptations RFC 041 admits, such as
floattof32orFrozenList[T]toVec<T>, must remain available. They must stay bounded by that table and visible in the target signature. -
An Incan trait must compile to a real Rust trait, and adopting it must compile to a real implementation.
-
All of these rules must hold whether Rust-authored code calls Incan-authored code (RFC 097) or Incan-authored code imports and uses Rust (RFC 005, RFC 041, RFC 043). A Rust type imported into Incan must remain that Rust type.
-
These rules cover representation, not operators. An Incan operator must keep its Incan meaning on a value, and a Rust operator its Rust meaning on the same value.
-
Any departure from these rules must be a named exception on a published list, with its reason, and with a diagnostic when code relies on identity the exception does not provide. An implementation must not fall back silently to conversion.
-
std.reflectionmust expose these facts.TypeInfomust provideidentity, the RFC 120 canonical identity printed as its full path, andrepresentation, the Rust type.FieldInfomust provideparent, the identity of the type that declares the field. RFC 128'sSignatureInfomust provide the declaration's identity, and each of its parameter entries the sameparent. -
incan inspect typesmust report, for a given type, its identity, its Rust representation, and any exception that applies to it. It must also list every named exception.
Calls into Rust¶
A call from Incan into an imported Rust item follows that item's inspected or shipped signature. These rules hold for free functions, methods, associated functions and constructors:
from rust::demo import create_widget, Config, Error, Stream
widget = create_widget(name="primary", enabled=true) # keywords bind by Rust parameter name
config = Config(port=8080) # other fields from Config's Default
stream = Stream.open[f32](config)? # explicit type arguments, checked by count
def on_error(error: Error) -> None:
println(error.to_string())
stream.on_error(on_error) # an Incan function where Rust takes a callable
-
Positional arguments must bind in the Rust parameter order. A keyword argument must bind to the Rust parameter of that name when the signature names it, and the call must lower to Rust's positional order. A keyword the signature does not name must be refused; it must not be mapped to a position by guessing.
-
A Rust struct with named fields may be constructed with keyword fields. A field may be omitted only when the type implements
Default; each omitted field then takes thatDefaultvalue. WithoutDefault, every field is required. -
Explicit type arguments must match the number of type parameters the Rust item declares, including the type parameters of the type a type-only generic item belongs to. A mismatch must be refused when checking, not when Rust compiles. A type argument may be inferred from an annotated result, also through
Result.unwrap(). A literal passed to a parameter bounded byInto[T]must stay inferable rather than becoming an ambiguous conversion. -
Ownership must follow the signature, under RFC 164: an owned parameter takes the value and may bind it mutably, a shared borrow passes
Borrowed[T], and an exclusive borrow requires amutbinding and passesBorrowedMut[T]. A trait-object argument must satisfy the required traits and preserve the ownership, mutability and lifetime requirements of the inspected Rust signature. A trait object may be shared-borrowed, exclusively borrowed or owned; the trait-object form alone must not select an ownership mode. A receiver that is a guard dereferencing to its target must not be confused with an owned receiver. No name, configuration key or manifest section may decide ownership instead of the signature. -
An Incan function or closure must be accepted where Rust expects a callable, through a callable bound or a Rust callable type alias, when its parameters and result match the bound under RFC 128. A callable bound whose parameters take shared slices must accept an Incan callable with the corresponding
Borrowed[list[T]]parameters, using rule 8's shared-view representation in both directions and retaining the source lifetime. A callable that does not match, such as one that takes by value what Rust lends, must be refused when checking. -
Arguments and results use the borrowed views of rule 8 and adapt only as rule 9 allows: a
list[T]reaches a borrowed slice parameter without a copy, astrreaches a borrowed string parameter, and a RustStringresult is an Incanstr. A RustResult<T, E>isResult[T, E], andusizeandisizeare themselves, as rule 2 requires. A Rust function whose result type is the diverging!returns a value that fits any expected type, because the call does not return. -
A Rust name that is an Incan keyword must remain reachable: an item through
asin its import, and a field through its keyword spelling in field access and keyword arguments. -
When the signature cannot decide one of these rules, for example because it names no parameters, the call must be refused with a diagnostic that names the Rust item and the missing fact. The compiler must not fall back to a guess, a copy or a conversion.
Design details¶
Integers and carriers¶
int is i64 by default because it means newtype Int[storage=i64], and RFC 009 keeps int and i64 interchangeable. RFC 091 owns which carriers exist and which values each type admits. This RFC adds only that the chosen carrier is the type's real Rust representation, so narrowing and widening both come with no conversion.
Why Dict is HashMap¶
RFC 041 already lowers Dict[K, V] to HashMap<K, V>, and that fixes its requirements: keys must be hashable and comparable, and iteration order is not promised. A program that needs an order uses RFC 030's OrderedDict or SortedDict, which are standard-library types under rule 4.
Frozen types¶
FrozenStr, FrozenBytes, FrozenList[T], FrozenDict[K, V], and FrozenSet[T] are standard-library types over read-only data, so rule 4 covers them. They are not Vec or HashMap with immutability added.
Their data is either baked into the program or produced while it runs. A constant's frozen value points at static data and costs nothing to keep. A frozen value that a decoder reconstructs (RFC 173), that reflection returns, or that a written __shape__ composes, points at shared, reference-counted storage instead. Either way, copying the value copies a handle and never the contents, which is how a newtype over FrozenStr or FrozenBytes stays copyable: an assignment or an argument copies the handle, and the original stays usable. Equality and hashing look at the contents, so a constant and a decoded value with the same contents are equal and hash the same. Leaking a value to give it static storage would cost memory for every decoded record, which is why shared storage exists. RFC 041's table lists those Rust types for them, and this RFC treats that as what rule 9 keeps: the adaptation a Rust API can ask for.
Traits and the exception list¶
The list starts with one entry:
- Trait fields (RFC 144): a Rust trait has no fields. An adopter's field is a real struct field, and the trait requirement appears in Rust as accessor methods. RFC 144's type-owned constants are not an exception, because Rust has associated constants.
Relationship to other RFCs¶
RFC 097 describes the caller boundary in terms of this RFC, and its type table lists identities. RFC 005, RFC 041 and RFC 043 are closed and stay as written, so this RFC and the language reference hold the contract where they differ.
Alternatives considered¶
- Keep Incan and Rust as two independently-typed systems, bridged by conversion at every boundary. It works, but it implies ongoing translation and checking cost, and duplicated logic per boundary, that a single Oven-owned build graph no longer requires.
- Apply this only to RFC 097's caller boundary. Rejected because it would leave the model asymmetric: one substrate calling in one direction, two systems calling in the other.
- A dedicated crate for shared standard-library types. Rejected because standard-library types already live in Loaves with RFC 120 identity, so a separate crate would add a second home for them.
- A per-call choice between
Stringand&str. Rejected because RFC 164 and RFC 129 already say how a value is passed, and strings need no policy of their own. - Fix
intati64everywhere. Rejected because it would take away RFC 009's exact-width types and RFC 091's carriers.
Drawbacks¶
- A built-in's Rust type becomes a promise. Changing what
Dictis in Rust would break Rust code that names it. - Without a check at the crossing, the guarantee rests on the compiler's own correctness rather than a runtime backstop.
- Rust code sees Incan's choices directly, including
i64indexes and owned strings where a hand-written Rust API might do otherwise. - The exception list has to be kept accurate, because it is what makes the rest of the contract trustworthy.
Layers affected¶
- Typechecker / language type system: each type's Rust representation is a checked fact, not a decision made during emission.
- IR Lowering / Emission: no conversion or adapter for identical types, and values pass through unchanged.
- Standard-library Loaves: own the single definition of each standard-library type.
- Reflection:
TypeInfo,FieldInfoandSignatureInfogain the facts in rule 14. - LSP / Tooling:
incan inspect typesand hover report representation, identity and exceptions. - Library artifacts: a Loaf records each public type's identity and representation, so consumers can confirm they agree.
Design Decisions¶
- Shared standard-library types live in the Loaf that declares them, with RFC 120 identity, so no separate crate exists.
Stringversus&stris not a separate policy.stris owned,Borrowed[str]is the borrowed form, and RFC 164 and RFC 129 decide how a value is passed.- Closed RFCs stay frozen. RFC 005, RFC 041 and RFC 043 are not edited, and RFC 097 is updated directly.
- Traits are covered, with named exceptions, starting with RFC 144's trait fields.
- The rule covers representation, not operators.
intisi64, while//rounds down and Rust's/truncates. intis a default carrier, not a fixed type. Nothing an author can declare under RFC 009 or RFC 091 is lost.- RFC 041's table is the base for built-ins, and its adaptations stay. Frozen types are the correction: they are standard-library types, with
Vec,HashMapandHashSetremaining as adaptations. - Representation is readable through reflection, and each field names its parent by identity.
- Frozen values are static or shared. A constant keeps its free static backing, and a frozen value produced at run time shares reference-counted storage, so decoding and composing frozen values neither copies contents on every pass nor leaks a value per record. Copying the contents on each copy, or giving reconstructed fields mutable collections instead, would each break what a frozen type promises.
- A call into Rust follows the inspected signature. Keyword binding,
Defaultfields, type-argument counts, ownership and callable bounds all come from the Rust item, so a call means what the Rust item declares, and a fact the signature lacks is refused rather than guessed.