How derives work (Explanation)¶
This page explains what Incan “derives” mean at compile time, without requiring you to read compiler source.
For the derive catalog and exact user-facing rules, see:
The intent¶
Incan’s derive system exists to give you out-of-the-box behavior (Debug, Display, Eq, Ord, Hash, Clone, Default, serde derives, etc.) without boilerplate.
At a high level:
- you write
@derive(...)on amodel/class/enum/newtype - the compiler generates the corresponding implementations in generated Rust (or compiler-injected impls)
Where the “derive vocabulary” lives¶
Incan’s stdlib contains trait definitions that act as documentation vocabulary for derive names and dunder hooks.
You may see @rust.extern in stdlib sources:
- it marks functions whose body is provided by Rust (via
rust.module()) - it marks “compiler-provided implementation” stubs in the stdlib
The compiler is responsible for providing the implementation; the stdlib is the stable vocabulary and signature registry.
A module derive follows the same pattern. @derive(json) adopts each trait the std.serde.json module lists in its __derives__, json.Serialize and json.Deserialize, and those traits carry the Rust derives (serde::Serialize, serde::Deserialize) the compiled type needs. The adopted traits then work like any with adoption: to_json() is found on the type, and the type satisfies a T with json.Serialize bound.
Derives vs dunders¶
Incan separates two cases:
- Derives: default, structural behavior (field-based)
- Dunder hooks: custom behavior (
__str__,__eq__,__lt__)
Hashing has only the derive: a set or dict hashes through @derive(Hash) or Hash in @rust.derive(...), and a method named __hash__ is an ordinary method that does not provide it.
If you try to do both for the same capability, that’s a conflict and should be treated as an error: the compiler must not have to guess which implementation “wins”.
Example conflicts:
@derive(Eq)
model User:
id: int
def __eq__(self, other: User) -> bool:
return self.id == other.id
The authoritative rule set (including the full conflict list) lives in:
How derive requirements are decided¶
A model, class or enum always derives Clone and Debug, so its field types must implement both (Automatic derives). A set compares and hashes its elements, and a dict its keys, so their types must implement Eq and Hash (Comparison → Hash). For both requirements the checker refuses a type only when it knows the type lacks the derive. It knows that for builtin types and for types declared in the module being checked. For a type declared in another module it knows only the derives that module's declaration lists, and a @rust.derive(...) there may add more, so such a type is never refused for a missing derive. A type parameter, a type imported from Rust and a rusttype are not refused; the Rust build is the final check for them.
A generic function or method hashes its type parameter T when its body uses a value of type T as a set literal element, a dict literal key or a dict comprehension key, calls set(...) on a collection of T, or passes T on to a function or method that hashes it. That requirement is part of the callable's signature, so it is known before any body is checked and whatever order the declarations come in. A compiled library records it in its manifest as Eq and Hash bounds on the type parameter, marked as inferred, and a consumer applies them under the same rule: a library type whose derives come from @rust.derive(...) is not refused.
The type argument checked is the one the call instantiates the parameter with, including the element type of a literal argument: unique([Tag.A]) instantiates T as Tag. A type parameter declared with (Eq, Hash) is checked by those bounds instead.
When a generic function passes its own type parameter on to a callee that hashes it, what happens depends on where the callee lives:
- A callee from the same module or an imported source module moves the requirement onto the calling function: the caller now hashes that parameter too, and its own calls are checked.
- A compiled library's callee cannot do that, because the caller's generated signature carries only the bounds its declaration spells. So the forwarded type parameter is refused with
INCAN-T0114unless it is declaredwith (Eq, Hash).
from pub::hashing import unique # a compiled library
def mine[T](items: list[T]) -> set[T]:
return unique(items) # refused: INCAN-T0114, T is not declared with (Eq, Hash)
def bounded[T with (Eq, Hash)](items: list[T]) -> set[T]:
return unique(items) # accepted
Non-goals (deliberate omissions)¶
Some Python/Rust features are intentionally not part of Incan’s trait/derive surface area:
- Context managers (
__enter__/__exit__): prefer scope-based cleanup RAII (Resource Acquisition Is Initialization) style - Destructors (
__del__/Dropas a user feature): cleanup is automatic; exposing destruction hooks adds complexity __format__:Display(__str__) + f-strings cover most needs
Resource management without context managers¶
Incan’s default approach is: allocate the resource, use it, and let scope end handle cleanup.
def process_file() -> Result[str, str]:
file = File.open("data.txt")?
content = file.read_all()?
return Ok(content)