How Sway Type System Performs Type Unification: A Deep Dive into the Compiler
Sway's type system performs type unification through a two-stage process involving UnifyCheck to verify compatibility and Unifier to perform actual type substitution in the TypeEngine, both implemented in the sway-core crate.
Type unification is the core algorithm that enables Sway's Hindley-Milner-style type inference, allowing the compiler to resolve generic types into concrete ones and verify that expressions match their expected types. In the FuelLabs/sway repository, this critical functionality resides in sway-core/src/type_system/unify/, where the compiler separates the feasibility check from the destructive update operation.
The Two-Stage Type Unification Architecture
Sway employs a strict separation of concerns when resolving type compatibility:
- UnifyCheck – A read-only utility that answers "Can these types be made equal?" without modifying the
TypeEngine. - Unifier – A write-capable struct that performs "Make these types equal" by updating type variables and generic parameters.
This design prevents partial mutations when unification fails halfway through complex structural types like structs or enums.
Stage 1: Type Compatibility Checking with UnifyCheck
Located in sway-core/src/type_system/unify/unify_check.rs, the UnifyCheck utility implements the classic occurs-check and structural comparison logic required for sound type inference.
UnifyCheck Modes and Semantics
UnifyCheck operates in three distinct modes via the UnifyCheckMode enum:
- Coercion – The most permissive check, asking "Can
leftbe coerced intoright?" Used during standard type inference when flexibility is required. - ConstraintSubset – Validates that
leftis a subset ofright, used for generic constraint checking. - NonDynamicEquality – Enforces that two concrete types are definitely equal, used when the compiler must reject ambiguous matches.
The UnifyCheck Algorithm
The public entry point UnifyCheck::check(left, right) executes the following steps:
- Fast-path equality – Returns
trueimmediately if bothTypeIds are identical. - Top-level generic shortcut – In
NonGenericConstraintSubsetmode, a generic on the right side matches any left side that satisfies its underlyingTypeInfo. - Recursive structural walk –
check_innerrecurses over arrays, slices, tuples, structs, enums, custom types, references, and generic parameters. - Mode-specific rules – In
Coercionmode,PlaceholderorUnknowntypes unify with any type, whileNonDynamicEqualityrejects these flexible types.
Preventing Infinite Types with OccursCheck
Before unifying a generic type variable with a concrete type, Sway performs an occurs-check via sway-core/src/type_system/unify/occurs_check.rs to prevent infinite recursive types like T = &[T]. The OccursCheck::new(...).check(generic, other) method traverses the prospective replacement type to ensure the generic variable does not appear within its own definition.
Stage 2: Type Substitution with Unifier
Once compatibility is confirmed, the Unifier struct in sway-core/src/type_system/unify/unifier.rs performs the actual type merging by updating the TypeEngine.
Unifier Structure and Configuration
The Unifier carries three critical pieces of state:
pub(crate) struct Unifier<'a> {
engines: &'a Engines, // Access to TypeEngine and DeclEngine
help_text: String, // User-facing error message context
unify_kind: UnifyKind, // Default / WithSelf / WithGeneric
}
UnifyKind determines which side may be more informative:
- Default – Standard bidirectional unification.
- WithSelf – Prefers replacing the expected side when the self-type appears on the right (used in
implblocks). - WithGeneric – Allows generic parameters on the expected side to be inferred from the received side.
The Unification Process
The Unifier::unify method follows this control flow:
- Record unification – Optionally logs the step for diagnostic purposes.
- Early exit – Returns immediately if
receivedandexpectedTypeIds are identical. - TypeInfo dispatch – Matches on the concrete
TypeInfovariants of both sides.
When one side is flexible (Unknown, Placeholder, or UnknownGeneric), the unifier calls replacement helpers:
fn replace_received_with_expected(&self, received: TypeId, expected_type_info: &TypeInfo, span: &Span) {
self.engines.te().replace_with_new_source_id(
self.engines,
received,
expected_type_info.clone(),
span.source_id().copied(),
);
}
Handling Complex Types
For structural types, unify delegates to specialized helpers:
- Structs –
unify_structsverifies that call paths and generic parameters match, then recurses on fields (lines 139-202 inunifier.rs). - Enums – Similar to structs but handles variant unification.
- Arrays – Checks element type unification and length compatibility.
- References – Handles mutability subtyping where
&mut Tcan coerce to&T. - Aliases – Re-enters
unifyon the underlying aliased type. - Never – The
Nevertype coerces to any type without error.
When structural mismatches occur, the unifier emits a MismatchedType error through the Handler, incorporating the help_text provided at construction.
Code Examples: Type Unification in Practice
Example 1: Primitive Type Checking
let engines = Engines::default();
let handler = Handler::default();
let span = Span::dummy();
let u8_id = engines.te().id_of_u8();
let u16_id = engines.te().id_of_u16();
let check = UnifyCheck::coercion(&engines);
assert!(!check.check(u8_id, u16_id)); // u8 cannot coerce to u16
let unifier = Unifier::new(&engines, "type mismatch", UnifyKind::Default);
unifier.unify(&handler, u8_id, u8_id, &span, true); // succeeds, no change
Source: unify_check.rs lines 38-45 (mode creation) and unifier.rs lines 19-31 (constructor) & 92-106 (early-exit).
Example 2: Generic to Concrete Unification
// Assume we have a generic type variable `T` (an UnknownGeneric) and a concrete `u64`.
let generic_t = engines.te().insert_unknown_generic(
Ident::new_with_override("T".into(), Span::dummy()),
VecSet::default(),
None,
false,
);
let u64_id = engines.te().id_of_u64();
let check = UnifyCheck::coercion(&engines);
assert!(check.check(generic_t, u64_id)); // generic can be coerced to concrete
let unifier = Unifier::new(&engines, "cannot infer generic", UnifyKind::Default);
unifier.unify(&handler, generic_t, u64_id, &span, true);
// The generic `T` is now replaced in the TypeEngine with `u64`.
Source: unify_check.rs lines 46-70 (generic coercion rule).
unifier.rs lines 101-108 (record unification) and 123-130 (replace unknown with expected).
Example 3: Struct Unification with Generics
// struct Pair<T, U> { a: T, b: U }
let pair_decl = engines.de().insert_struct(
CallPath { prefixes: vec![], suffix: Ident::new_with_override("Pair".into(), Span::dummy()), callpath_type: CallPathType::Full },
vec![
// fields
TyStructField { name: Ident::new_with_override("a".into(), Span::dummy()), type_argument: GenericTypeArgument { type_id: generic_t, .. } },
TyStructField { name: Ident::new_with_override("b".into(), Span::dummy()), type_argument: GenericTypeArgument { type_id: generic_u, .. } },
],
// generic parameters
vec![
TypeParameter::Type(generic_t_param),
TypeParameter::Type(generic_u_param),
],
);
let pair_type_id = engines.te().insert_struct(pair_decl, ...);
// Another concrete instance `Pair<u8, u64>`
let concrete_pair = ... // similar but with concrete type IDs for `u8` and `u64`.
let check = UnifyCheck::coercion(&engines);
assert!(check.check(pair_type_id, concrete_pair));
let unifier = Unifier::new(&engines, "struct mismatch", UnifyKind::Default);
unifier.unify(&handler, pair_type_id, concrete_pair, &span, true);
// The generic parameters inside the first struct are now bound to `u8` and `u64`.
Source:
- Struct unification logic –
unifier.rslines 93-102 (Structarm) →unify_structs(lines 139-202). - Generic handling inside
unify_structs– lines 166-190 (field-wise recursion).
Key Source Files and Implementation Details
| File | Purpose | Link |
|---|---|---|
sway-core/src/type_system/unify/unify_check.rs |
Boolean feasibility check for unification (coercion, constraint subset, non-dynamic equality). | view |
sway-core/src/type_system/unify/unifier.rs |
Performs the actual type substitution in the TypeEngine. Handles all concrete cases (primitives, structs, enums, arrays, refs, placeholders, generics, etc.). |
view |
sway-core/src/type_system/unify/occurs_check.rs |
Prevents infinite recursive types (T occurring inside its own definition). |
view |
sway-core/src/type_system/info.rs |
Definition of TypeInfo, the enum that represents every possible type in Sway. |
view |
sway-core/src/type_system/mod.rs |
Re-exports the unifier utilities for the rest of the compiler. | view |
These files collectively implement a Hindley-Milner-style unification algorithm extended for Sway's specific concepts (placeholders, const generics, contract callers, and trait-constraint subtyping).
Summary
- Sway type system type unification operates through a strict two-phase architecture:
UnifyCheckfor read-only feasibility testing andUnifierfor write-enabled type substitution. - Three checking modes—Coercion, ConstraintSubset, and NonDynamicEquality—provide different levels of strictness for various compiler phases.
- Structural recursion handles complex types including structs, enums, arrays, and references, with special logic for generic parameter binding.
- Occurs-check protection prevents infinite recursive type definitions like
T = &[T]before unification commits to a substitution. - UnifyKind configuration (Default, WithSelf, WithGeneric) controls directional preferences when unifying self-types or generic constraints.
Frequently Asked Questions
What is the difference between UnifyCheck and Unifier in Sway?
UnifyCheck is a read-only utility that returns a boolean indicating whether two types can be unified without modifying the TypeEngine. It implements the occurs-check and structural comparison logic. In contrast, Unifier is a write-capable struct that actually performs the substitution, updating type variables and generic parameters in the TypeEngine when the compiler commits to a unification decision.
How does Sway prevent infinite recursive types during unification?
Sway implements an occurs-check via sway-core/src/type_system/unify/occurs_check.rs before performing any substitution. This check traverses the prospective replacement type to ensure that the generic type variable being unified does not occur within its own definition, preventing infinite types such as T = &[T] from being constructed.
What are the different modes of type checking in Sway's UnifyCheck?
UnifyCheck supports three distinct modes via UnifyCheckMode: Coercion (the most permissive, allowing flexible type inference), ConstraintSubset (validating that one type satisfies the constraints of another for generic bounds), and NonDynamicEquality (strict equality for concrete types where ambiguity must be rejected). Each mode activates different rules for handling placeholders, unknown generics, and structural types.
How does Sway handle generic type parameters during unification?
When unifying types containing generic parameters, Sway's Unifier recursively processes the generic arguments alongside the main type structure. For struct and enum unification, the compiler verifies that call paths match and then calls unify on each generic parameter, effectively binding unknown generics to concrete types (such as replacing T with u64) through the replace_received_with_expected helper in unifier.rs.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →