Design Exploration & Specification
Design Note: This document describes Hexen's comptime type system, following proven patterns from languages like Zig while focusing on runtime cost transparency and ergonomic literal usage.
Hexen's type system is designed around two core principles: "Ergonomic Literals" and "Transparent Runtime Costs" - making common literal usage seamless while keeping all runtime computational costs visible. This philosophy aims to create a system where everyday coding feels natural, but runtime performance costs are always explicit and visible.
Hexen follows a simple, unified pattern that makes common cases ergonomic while keeping runtime costs visible:
- Ergonomic Literals: Comptime types adapt seamlessly (zero runtime cost)
- Transparent Runtime Costs: All runtime conversions require explicit syntax (
value:type) - Hidden Compile-Time Costs: Acceptable because they don't affect runtime performance
- Natural Usage: Common literal patterns work without ceremony (
42,3.14) - Visible Conversions: Runtime performance costs are always explicit in the code
- Predictable Rules: Same simple pattern everywhere (minimal cognitive load)
This philosophy aims to ensure that everyday coding feels natural, while runtime performance costs are always explicit and visible.
Before diving into the type system, it's essential to understand Hexen's two variable declaration keywords that you'll see throughout this document:
The val keyword declares immutable variables - variables that can only be assigned once at declaration:
val message = "Hello, World!" // ✅ Immutable variable
val count = 42 // ✅ Single assignment at declaration
// count = 43 // ❌ Error: Cannot reassign val variable
Key characteristics:
- Single assignment: Can only be set once (at declaration)
- Type inference allowed: Can omit type annotation when using comptime literals
- Name origin: "val" stands for "value" - representing a single, unchanging value
The mut keyword declares mutable variables - variables that can be reassigned after declaration:
mut counter : i32 = 0 // ✅ Mutable variable with explicit type
counter = 42 // ✅ Reassignment allowed
counter = 100 // ✅ Multiple reassignments allowed
Key characteristics:
- Multiple assignments: Can be reassigned as many times as needed
- Explicit type required: Must specify type annotation to prevent action-at-a-distance effects
- Name origin: "mut" stands for "mutable" - representing a changeable variable
This distinction serves important design goals:
- Safety by Default:
valencourages immutable-first programming - Clear Intent: The keyword immediately tells you if a variable can change
- Type System Benefits: Different rules for type inference and comptime type preservation
- Performance: Compiler can optimize better knowing what can/cannot change
- Use
valfor: Constants, computed results, configuration values that don't change - Use
mutfor: Counters, accumulators, state variables that need updates
// ✅ Good usage patterns
val config_file = "app.toml" // Configuration - doesn't change
val result : i32 = compute_expensive() // Computed result - explicit type required for function calls
mut counter : i32 = 0 // Counter - will be incremented
mut buffer : string = "" // Buffer - will be appended to
// ❌ Poor usage patterns
mut constant_pi : f64 = 3.14159 // Should be val - never changes
val accumulator : i32 = 0 // Should be mut - probably needs updates
Now that you understand val and mut, let's explore how they interact with Hexen's type system.
| Type | Description | Size | Range |
|---|---|---|---|
i32 |
32-bit signed integer | 4 bytes | -2,147,483,648 to 2,147,483,647 |
i64 |
64-bit signed integer | 8 bytes | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
f32 |
32-bit IEEE 754 float | 4 bytes | ±1.18×10⁻³⁸ to ±3.40×10³⁸ |
f64 |
64-bit IEEE 754 float | 8 bytes | ±2.23×10⁻³⁰⁸ to ±1.80×10³⁰⁸ |
string |
UTF-8 string | Variable | Arbitrary length |
bool |
Boolean value | 1 byte | true or false |
void |
No value (functions only) | 0 bytes | N/A |
| Type | Description | Size | Range | Purpose |
|---|---|---|---|---|
usize |
Platform-dependent unsigned integer | 4 or 8 bytes | 0 to 4,294,967,295 (32-bit) 0 to 18,446,744,073,709,551,615 (64-bit) |
Array indexing only |
Critical Distinction:
- User types (
i32,i64,f32,f64): General-purpose values for computation, variables, function parameters - Index type (
usize): Exclusively for array indexing and slicing operations
Key Properties:
- Platform-dependent: Size matches pointer width (32-bit or 64-bit platform)
- Unsigned: Only non-negative values (array indices can't be negative)
- Special purpose: Used exclusively for array operations (see RANGE_SYSTEM.md)
- Universal indexing: A single
usizevalue works for arrays of any element type
| Type | Description | Purpose |
|---|---|---|
comptime_int |
Integer literals | Context-dependent coercion to any numeric type |
comptime_float |
Float literals | Context-dependent coercion to float types |
Why Only Numeric Comptime Types?
Following proven patterns from languages like Zig, Hexen provides comptime types only where meaningful flexibility exists:
- ✅ Numeric literals need flexibility:
42can meaningfully becomeu8,i32,i64,f32,f64(different representations) - ✅ Float literals need precision control:
3.14can meaningfully becomef32,f64(different precisions) - ❌ String literals don't need flexibility:
"hello"has fixed size determined by content - ❌ Bool literals don't need flexibility:
true/falsehave only two values
📋 Important: For detailed information about literal overflow behavior and compile-time safety guarantees, see LITERAL_OVERFLOW_BEHAVIOR.md.
| Type | Description | Usage |
|---|---|---|
unknown |
Type inference failure | Error handling |
undef |
Uninitialized variable | Explicit uninitialized state |
🚀 Quick Start: New to comptime types? Check out the Comptime Quick Reference → for essential patterns and mental models!
Systems programming languages face a fundamental tension between ergonomic literals and runtime cost visibility:
// C/C++ - requires explicit suffixes or casting
int32_t a = 42; // OK, but what if we want i64?
int64_t b = 42L; // Requires suffix
float c = 42.0f; // Requires suffix
double d = 42.0; // OK, but inconsistent with intThis creates three problems:
- Suffix Hell: Remembering and typing suffixes for every literal
- Inflexibility: Hard to change types without updating all literals
- Hidden Runtime Costs: Automatic conversions can hide performance implications
The challenge: How to get ergonomics WITHOUT hiding runtime costs?
Hexen solves this with comptime types - special types that literals have initially, which adapt to context with zero runtime cost. For concrete types, all conversions are explicit:
📋 Overflow Safety: Hexen uses compile-time overflow detection for literal safety. See LITERAL_OVERFLOW_BEHAVIOR.md for complete safety guarantees.
// ✨ Comptime literals adapt seamlessly (ergonomic)
val small : i32 = 42 // comptime_int → i32 (implicit, no cost)
val large : i64 = 42 // comptime_int → i64 (implicit, no cost)
val precise : f64 = 42 // comptime_int → f64 (implicit, no cost)
val double : f64 = 3.14 // comptime_float → f64 (implicit, no cost)
val single : f32 = 3.14 // comptime_float → f32 (implicit, no cost)
// 🔧 All concrete conversions are explicit (transparent costs)
val converted : i32 = float_val:i32 // f64 → i32 (explicit conversion, visible cost)
val widened : i64 = int_val:i64 // i32 → i64 (explicit conversion, visible cost)
When the parser encounters a literal, it assigns a comptime type:
42 // Gets type: comptime_int
-123 // Gets type: comptime_int
3.14 // Gets type: comptime_float
-2.5 // Gets type: comptime_float
During type checking, comptime types look at their usage context and adapt:
// Context comes from variable declaration
val counter : i32 = 42 // comptime_int → i32 (safe, implicit)
val big_counter : i64 = 42 // comptime_int → i64 (safe, implicit)
val percentage : f32 = 42 // comptime_int → f32 (safe, implicit)
// Context comes from function parameters
func process_data(count: i64) { ... }
process_data(1000) // comptime_int sees i64 parameter → becomes i64
// Context comes from return types
func get_count() : i32 = { // return type provides context
return 42 // comptime_int sees i32 return → becomes i32
}
Not all comptime type adaptations are automatic - some require explicit conversion to prevent data loss:
// ✅ Safe adaptations (automatic)
val int_as_i32 : i32 = 42 // comptime_int → i32 (safe, implicit)
val int_as_f64 : f64 = 42 // comptime_int → f64 (safe, implicit)
val float_as_f32 : f32 = 3.14 // comptime_float → f32 (safe, implicit)
// ❌ Unsafe adaptations require explicit conversion
// val truncated : i32 = 3.14 // Error: potential data loss
// val truncated : i64 = 3.14 // Error: potential data loss
// ✅ Explicit conversion for unsafe operations
val truncated : i32 = 3.14:i32 // comptime_float → i32 (explicit truncation)
val truncated : i64 = 3.14:i64 // comptime_float → i64 (explicit truncation)
Rule: comptime_float can adapt automatically to float types (f32, f64) but requires explicit conversion to integer types (i32, i64) because truncation loses data.
When there's no explicit type context, comptime types preserve flexibility:
// val allows comptime type preservation (flexibility)
val flexible_int = 42 // comptime_int (preserved - can adapt to any numeric context later!)
val flexible_float = 3.14 // comptime_float (preserved - can adapt to f32/f64 context later!)
// Later usage provides context for resolution
val small : i32 = flexible_int // NOW comptime_int → i32 (context-driven)
val large : i64 = flexible_int // SAME source → i64 (different context!)
val precise : f64 = flexible_int // SAME source → f64 (flexible adaptation!)
// mut requires explicit type (prevents action-at-a-distance)
mut counter : i32 = 42 // ✅ Explicit type required
mut pi : f64 = 3.14 // ✅ Explicit type required
// mut bad_counter = 42 // ❌ Error: mut requires explicit type
// mut bad_pi = 3.14 // ❌ Error: mut requires explicit type
Key Insight: Comptime types stay flexible until forced - they don't automatically become concrete types!
All comptime arithmetic happens at compile-time with maximum precision, providing significant benefits:
// All intermediate calculations use high precision
val complex_calc = 3.14159265358979 * 10000000 - 31415926 // High precision comptime
// Runtime: just load different pre-computed constants (zero arithmetic cost)
val as_f32 : f32 = complex_calc // High precision → f32 (single conversion)
val as_f64 : f64 = complex_calc // Same high precision → f64 (single conversion)
Benefits:
- Maximum accuracy: No intermediate precision loss
- Zero runtime cost: All computation at compile-time
- Same source, multiple targets: One calculation, many precisions
- Proven approach: Following successful patterns from Zig
Comptime types prioritize flexibility preservation over premature resolution:
❌ Traditional Approach: "Literals have default types"
42becomesintimmediately (or requires suffixes like42L)- Hard to change types without updating all literals
- Runtime arithmetic costs for each usage
✅ Comptime Approach: "Literals preserve flexibility"
42stayscomptime_intuntil context forces resolution- Same literal adapts to any compatible context
- Zero runtime cost (compile-time evaluation)
This enables interesting patterns:
val magic_number = 42 * 1000 + 500 // Stays comptime_int (flexible!)
// Later, same expression used in different contexts:
val config_id : i32 = magic_number // Resolves to i32
val timestamp : i64 = magic_number // Same expr, resolves to i64
val ratio : f64 = magic_number // Same expr, resolves to f64
// Function calls get the right types automatically:
process_i32(magic_number) // Resolves to i32 for function parameter
process_i64(magic_number) // Resolves to i64 for function parameter
Result: Write code once, use everywhere - the type system adapts to your needs instead of forcing you to adapt to the type system.
While comptime types can adapt to any compatible concrete type, the reverse is forbidden. Once a value becomes concrete (from function calls, runtime computation, etc.), it cannot be forced back into comptime:
// ✅ Comptime → Concrete (Always Allowed)
val flexible = 42 // comptime_int
val concrete : i32 = flexible // comptime_int → i32 (✅ context-driven)
// ✅ Concrete → Concrete (Explicit Types Required)
val runtime_result : i32 = compute_value() // ✅ Explicit type required for concrete values
val another : i32 = runtime_result // i32 → i32 (identity, no conversion)
val widened : i64 = runtime_result:i64 // i32 → i64 (explicit conversion required)
// ✅ Mixed: Concrete + Comptime (Comptime Adapts)
val runtime_result : i32 = compute_value() // ✅ Explicit type required for concrete values
val mixed : i32 = runtime_result + 100 // i32 + comptime_int → i32 (comptime adapts to i32)
Comparison Example - Comptime vs Concrete Sources:
// ===== COMPTIME SOURCE (Flexible Adaptation) =====
val flexible_math = 42 + 100 // comptime_int (stays flexible!)
val as_i32 : i32 = flexible_math // comptime_int → i32 ✅
val as_i64 : i64 = flexible_math // Same source → i64 ✅
val as_f64 : f64 = flexible_math // Same source → f64 ✅
// ===== CONCRETE SOURCE (No Flexibility) =====
val concrete_math : i32 = compute_value() + other_value() // Explicit type required! i32 + i32 → i32 (concrete result)
val copy : i32 = concrete_math // i32 → i32 ✅ (identity)
val widened : i64 = concrete_math:i64 // i32 → i64 ✅ (explicit conversion required)
// val bad : comptime_int = concrete_math // ❌ Error: Cannot force concrete into comptime
Key Insights:
- Flexibility flows one direction only - from comptime to concrete, never the reverse
- Comptime types: Get type inference (
val x = 42) - flexible adaptation - Concrete types: Require explicit types (
val x : i32 = compute()) - transparent costs - This maintains the compile-time/runtime boundary while maximizing ergonomics
The comptime system embodies Hexen's "Ergonomic Literals + Transparent Costs" principle:
- Ergonomic Literals: Comptime types adapt seamlessly (no conversion cost at runtime)
- Explicit Conversions: All concrete type mixing requires visible syntax
- Cost Visibility: Every conversion is transparent in the code
- Predictable Rules: Simple, consistent behavior everywhere
Comptime types can coerce implicitly to all types in their allowed table:
comptime_int → All Numeric Types (Implicit)
val a : i32 = 42 // ✅ Safe: comptime_int → i32 (implicit)
val b : i64 = 42 // ✅ Safe: comptime_int → i64 (implicit)
val c : f32 = 42 // ✅ Safe: comptime_int → f32 (implicit)
val d : f64 = 42 // ✅ Safe: comptime_int → f64 (implicit)
comptime_float → Float Types Only (Implicit)
val e : f32 = 3.14 // ✅ Safe: comptime_float → f32 (implicit)
val f : f64 = 3.14 // ✅ Safe: comptime_float → f64 (implicit)
Outside the Table = Explicit Conversion Required
// ❌ Unsafe conversions require explicit conversion
// val g : i32 = 3.14 // Error: comptime_float → i32 requires ':i32'
// val h : i64 = 3.14 // Error: comptime_float → i64 requires ':i64'
// ✅ Explicit conversion of unsafe operations
val g : i32 = 3.14:i32 // comptime_float → i32 (explicit truncation)
val h : i64 = 3.14:i64 // comptime_float → i64 (explicit truncation)
Why this rule matters:
- Clear Safety Boundary: Everything in the table is guaranteed safe
- Explicit Danger: Conversions that lose data require explicit conversion
- No Arbitrary Restrictions: All safe conversions work seamlessly
- Consistent Pattern: Same explicit conversion pattern for all unsafe operations
Think of comptime types as "adaptive literals" that preserve flexibility: "I'll stay flexible until you need me to be concrete!"
- Parser: Creates comptime_int/comptime_float for all numeric literals
- Type Checker: Preserves comptime types until explicit context forces resolution
- Resolution: Only happens when comptime meets concrete type or explicit annotation
- Error: If conversion is unsafe or context requires explicit choice, compilation fails with helpful message
Think of comptime types as "maximally flexible values":
val flexible = 42 + 100 // Stays comptime_int (preserved!)
val small : i32 = flexible // NOW resolves to i32 (context forces resolution)
val large : i64 = flexible // SAME source, different target (flexible adaptation!)
Key insight: Comptime types preserve flexibility rather than resolve to defaults. The same comptime expression can resolve to different concrete types based on usage context.
This aims to create a clean, consistent system where literals stay flexible until context demands specificity, balancing both ergonomics and type safety.
Comptime types work seamlessly in function calls and complex expressions:
// Function parameters provide context for literals
func process_data(count: i64, threshold: f32) : void = {
// Function body uses parameters (implementation not shown)
}
// Literals adapt to parameter types automatically
process_data(1000, 2.5) // 1000→i64, 2.5→f32 (comptime adaptation)
// Return type provides context too
func get_count() : i32 = { // return type provides context
return 42 // comptime_int adapts to i32 return type
}
func get_ratio() : f64 = { // return type provides context
return 3.14 // comptime_float adapts to f64 return type
}
// Variable declarations provide context
val precise : f64 = 42 + 100 // comptime_int + comptime_int → comptime_int → f64 (context-driven)
val integer : i32 = 42 + 100 // comptime_int + comptime_int → comptime_int → i32 (context-driven)
// Same expression, different contexts - demonstrating flexibility!
val arithmetic = 10 * 20 + 5 // comptime_int (preserved until context forces resolution)
val as_small : i32 = arithmetic // NOW resolves to i32 based on target type
val as_precise : f64 = arithmetic // SAME expr, different resolution!
Same types work automatically:
val x : i32 = some_i32_value // i32 → i32 (no conversion)
val y : f64 = some_f64_value // f64 → f64 (no conversion)
comptime_int can adapt to:
i32,i64(user integer types)f32,f64(user float types)usize(platform index type - for array indexing)- Cannot adapt to
bool,string(not meaningful)
comptime_float can adapt to:
f32,f64(user float types)- Cannot adapt to
bool,string,i32,i64,usize(not meaningful without explicit conversion)
Key Insight: comptime_int can adapt to usize for ergonomic array indexing, but comptime_float cannot (fractional indices are meaningless).
// ✅ Comptime literals adapt seamlessly (ergonomic)
val int_var : i32 = 42 // comptime_int → i32 (implicit, no cost)
val float_var : f64 = 42 // comptime_int → f64 (implicit, no cost)
val precise : f32 = 3.14 // comptime_float → f32 (implicit, no cost)
// ❌ Comptime types that don't make sense (compilation errors)
// val bad_bool : bool = 42 // Error: use explicit logic instead
// val bad_string : string = 3.14 // Error: not meaningful
// val bad_int : i32 = 3.14 // Error: use explicit conversion
// ✅ Explicit conversions when needed
val explicit_int : i32 = 3.14:i32 // comptime_float → i32 (explicit conversion)
val explicit_logic : bool = (42 != 0) // Explicit boolean logic
For concrete (non-comptime) types, all conversions require explicit syntax:
// 🔧 All concrete conversions are explicit (transparent costs)
val widened : i64 = i32_value:i64 // i32 → i64 (explicit widening)
val precise : f64 = f32_value:f64 // f32 → f64 (explicit widening)
val converted : f32 = i32_value:f32 // i32 → f32 (explicit conversion)
val narrowed : i32 = i64_value:i32 // i64 → i32 (explicit narrowing, potential data loss)
val truncated : i32 = f64_value:i32 // f64 → i32 (explicit truncation, data loss)
// ❌ No automatic widening or conversion
// val auto_wide : i64 = i32_value // Error: use i32_value:i64
// val auto_convert : f64 = f32_value // Error: use f32_value:f64
Conversion Philosophy:
- All costs visible: Every conversion is explicit in the code
- No surprises: No hidden performance costs or data loss
- Uniform syntax:
value:target_typefor all concrete conversions
The usize type is a special platform-dependent unsigned integer type used exclusively for array indexing and slicing operations.
Key Properties:
- Platform-dependent: 32-bit or 64-bit based on platform pointer size
- Unsigned: Only non-negative values (0 to max)
- Special purpose: Used exclusively for array operations
- Universal: Works with arrays of any element type
Comptime int adaptation (ergonomic):
// ✅ Comptime literals adapt to usize (ergonomic array indexing!)
val index : usize = 42 // comptime_int → usize (implicit, no cost)
val arr : [_]i32 = [10, 20, 30, 40, 50]
val elem : i32 = arr[0] // comptime_int → usize (implicit)
val slice : [_]i32 = arr[1..4] // comptime_int → usize for both bounds
User type to usize (explicit):
// 🔧 User types require explicit conversion to usize
val start : i32 = 5
val end : i32 = 10
// ❌ Error: user type cannot be used directly for indexing
// val slice : [_]i32 = arr[start..end]
// ✅ Explicit conversion required
val idx_start : usize = start:usize
val idx_end : usize = end:usize
val slice : [_]i32 = arr[idx_start..idx_end]
// Or inline conversion:
val slice2 : [_]i32 = arr[(start:usize)..(end:usize)]
Float to usize (forbidden):
// ❌ Float types CANNOT convert to usize (even with explicit conversion)
val float_idx : f32 = 2.5
// val bad_idx : usize = float_idx:usize // ❌ Compilation error!
// Rationale: Fractional indices are meaningless for array positions
// If you must, use explicit truncation chain: f32 → i32 → usize
val truncated : usize = (float_idx:i32):usize // ✅ Two-step conversion (very explicit!)
usize to user types (explicit):
// 🔧 Converting usize back to user types requires explicit conversion
val index : usize = 42
val as_i32 : i32 = index:i32 // usize → i32 (explicit)
val as_i64 : i64 = index:i64 // usize → i64 (explicit)
val as_f64 : f64 = index:f64 // usize → f64 (explicit)
Design rationale:
- Separation of concerns: User types for computation,
usizefor indexing - Platform independence: Code works correctly on 32-bit and 64-bit platforms
- Type safety: Prevents accidental mixing of indices with values
- Ergonomic literals:
comptime_intadapts seamlessly tousizefor common case - Explicit conversions: User type →
usizerequires visible syntax (transparent costs)
For complete range system details: See RANGE_SYSTEM.md
| From Type | To Type | Conversion | Required Syntax | Notes |
|---|---|---|---|---|
| Comptime Types (Ergonomic Literals) | ||||
comptime_int |
comptime_int |
✅ Preserved | val x = 42 |
Comptime type preserved (flexible adaptation!) |
comptime_int |
i32 |
✅ Implicit | val x : i32 = 42 |
No cost, ergonomic |
comptime_int |
i64 |
✅ Implicit | val x : i64 = 42 |
No cost, ergonomic |
comptime_int |
f32 |
✅ Implicit | val x : f32 = 42 |
No cost, ergonomic |
comptime_int |
f64 |
✅ Implicit | val x : f64 = 42 |
No cost, ergonomic |
comptime_int |
usize |
✅ Implicit | val x : usize = 42 |
No cost, ergonomic (array indexing!) |
comptime_int |
bool |
❌ Forbidden | N/A | Use explicit logic: (42 != 0) |
comptime_int |
string |
❌ Forbidden | N/A | Not meaningful |
comptime_float |
comptime_float |
✅ Preserved | val x = 3.14 |
Comptime type preserved (flexible adaptation!) |
comptime_float |
f32 |
✅ Implicit | val x : f32 = 3.14 |
No cost, ergonomic |
comptime_float |
f64 |
✅ Implicit | val x : f64 = 3.14 |
No cost, ergonomic |
comptime_float |
i32 |
🔧 Explicit | val x : i32 = 3.14:i32 |
Conversion cost visible |
comptime_float |
i64 |
🔧 Explicit | val x : i64 = 3.14:i64 |
Conversion cost visible |
comptime_float |
usize |
❌ Forbidden | N/A | Float indices meaningless (no conversion!) |
comptime_float |
bool |
❌ Forbidden | N/A | Use explicit logic: (3.14 != 0.0) |
comptime_float |
string |
❌ Forbidden | N/A | Not meaningful |
| Concrete Types (All Explicit) | ||||
i32 |
i64 |
🔧 Explicit | val x : i64 = i32_val:i64 |
Conversion cost visible |
i32 |
f32 |
🔧 Explicit | val x : f32 = i32_val:f32 |
Conversion cost visible |
i32 |
f64 |
🔧 Explicit | val x : f64 = i32_val:f64 |
Conversion cost visible |
i32 |
usize |
🔧 Explicit | val x : usize = i32_val:usize |
Conversion cost visible (for array indexing) |
i64 |
i32 |
🔧 Explicit | val x : i32 = i64_val:i32 |
Conversion + data loss visible |
i64 |
f32 |
🔧 Explicit | val x : f32 = i64_val:f32 |
Conversion + precision loss visible |
i64 |
f64 |
🔧 Explicit | val x : f64 = i64_val:f64 |
Conversion cost visible |
i64 |
usize |
🔧 Explicit | val x : usize = i64_val:usize |
Conversion cost visible (for array indexing) |
f32 |
f64 |
🔧 Explicit | val x : f64 = f32_val:f64 |
Conversion cost visible |
f64 |
f32 |
🔧 Explicit | val x : f32 = f64_val:f32 |
Conversion + precision loss visible |
f32 |
i32 |
🔧 Explicit | val x : i32 = f32_val:i32 |
Conversion + data loss visible |
f64 |
i32 |
🔧 Explicit | val x : i32 = f64_val:i32 |
Conversion + data loss visible |
f32 |
i64 |
🔧 Explicit | val x : i64 = f32_val:i64 |
Conversion + data loss visible |
f64 |
i64 |
🔧 Explicit | val x : i64 = f64_val:i64 |
Conversion + data loss visible |
f32 |
usize |
❌ Forbidden | N/A | Float → usize conversion forbidden (use truncation: f32→i32→usize) |
f64 |
usize |
❌ Forbidden | N/A | Float → usize conversion forbidden (use truncation: f64→i64→usize) |
usize |
i32 |
🔧 Explicit | val x : i32 = usize_val:i32 |
Conversion cost visible |
usize |
i64 |
🔧 Explicit | val x : i64 = usize_val:i64 |
Conversion cost visible |
usize |
f32 |
🔧 Explicit | val x : f32 = usize_val:f32 |
Conversion cost visible |
usize |
f64 |
🔧 Explicit | val x : f64 = usize_val:f64 |
Conversion cost visible |
| Identity (No Conversion) | ||||
| Any type | Same type | ✅ Identity | val x : i32 = i32_val |
No conversion needed |
| Forbidden Conversions | ||||
| Any numeric | bool |
❌ Forbidden | N/A | Use explicit comparison: (value != 0) |
| Any numeric | string |
❌ Forbidden | N/A | Use string formatting functions |
bool |
Any numeric | ❌ Forbidden | N/A | Use conditional expression |
string |
Any numeric | ❌ Forbidden | N/A | Use parsing functions |
- ✅ Preserved: Comptime type stays flexible, maximum adaptability (comptime types only)
- ✅ Implicit: Happens automatically, no conversion cost (comptime types only)
- 🔧 Explicit: Requires explicit syntax (
value:type), conversion cost visible - ❌ Forbidden: Not allowed, compilation error
Binary operations in Hexen follow the explicit conversion strategy with transparent cost visibility. All concrete type mixing requires explicit conversions. Due to the complexity and importance of this topic, it has been moved to a dedicated specification:
→ See BINARY_OPS.md for complete binary operations specification
Key highlights:
- Explicit conversions: All concrete type mixing requires
value:typesyntax - Cost transparency: Every conversion is visible in the code
- Comptime adaptation: Literals adapt seamlessly (ergonomic for common cases)
- No hidden costs: No automatic widening or promotion
- Predictable rules: Simple, consistent behavior everywhere
- Implementation guidelines for semantic analyzer
Hexen provides two variable declaration keywords with distinct mutability characteristics:
- Single Assignment: Can only be assigned once at declaration
- Compile-time Enforcement: Reassignment attempts cause compilation errors
- Type Context: Target type provides context for comptime literal adaptation
- Use Case: Constants, configuration values, computed results that shouldn't change
val config : string = "production" // ✅ OK: initialization
val result : i32 = compute_value() // ✅ OK: initialization with function call
val derived : f64 = result * 2.5 // ✅ OK: initialization with expression
// config = "development" // ❌ Error: Cannot reassign val variable
// result = 42 // ❌ Error: Cannot reassign val variable
- Multiple Assignment: Can be reassigned after declaration
- Explicit Type Required: Must have explicit type annotation to prevent action-at-a-distance issues
- Type Preservation: Must maintain the same declared type across reassignments
- Type Context: Explicit type provides context for all reassignments
- Use Case: Counters, accumulators, state variables that need to change
mut counter : i32 = 0 // ✅ OK: explicit type required
counter = 42 // ✅ OK: comptime_int adapts to i32 context
counter = compute_value() // ✅ OK: if compute_value() returns i32
counter = large_value:i32 // ✅ OK: explicit conversion (e.g., i64 → i32)
// mut bad_counter = 0 // ❌ Error: mut requires explicit type
// counter = "text" // ❌ Error: Cannot change type (i32 → string)
// counter = float_val // ❌ Error: use float_val:i32 for explicit conversion
valvariables: Type inference allowed since declaration = only use (enables comptime type preservation)mutvariables: Explicit type required since type affects all future reassignments (prevents comptime type preservation)
Design rationale: mut variables require explicit types to prevent "action at a distance" where changing the initial assignment value could silently change the meaning of all subsequent reassignments.
🔴 Critical Consequence: This design choice means mut variables can never preserve comptime types - they sacrifice flexibility for safety. Only val declarations can preserve comptime types for later context-dependent resolution.
The target type of a variable declaration provides context for expression analysis:
// Target type guides expression resolution
val precise : f64 = 42 // comptime_int → f64
val integer : i32 = 42 // comptime_int → i32
val float_val : f32 = 3.14 // comptime_float → f32
Assignment statements use the target variable's type as context:
mut flexible : f64 = 0.0
flexible = 42 // comptime_int → f64 (assignment context)
For detailed rules about assignment context, type annotations, and mixed type operations, see BINARY_OPS.md.
Mutable variables (mut) can be reassigned while maintaining their declared type. The target type provides context for all assignments, and comptime types adapt naturally to this context.
// Integer reassignment
mut counter : i32 = 0
counter = 42 // comptime_int → i32
counter = -100 // comptime_int → i32
counter = 65535 // comptime_int → i32
// Float reassignment
mut precise : f32 = 0.0
precise = 3.14 // comptime_float → f32
precise = -2.5 // comptime_float → f32
precise = 0.0001 // comptime_float → f32
// String reassignment
mut message : string = ""
message = "hello" // string → string
message = "world" // string → string
// Boolean reassignment
mut flag : bool = false
flag = true // bool → bool
flag = false // bool → bool
Each type has specific reassignment rules:
// Integer types
mut small : i32 = 0
mut large : i64 = 0
// Safe integer reassignments
small = 42 // comptime_int → i32
large = 42 // comptime_int → i64
large = 4294967295 // comptime_int → i64
// Float types
mut single : f32 = 0.0
mut double : f64 = 0.0
// Safe float reassignments
single = 3.14 // comptime_float → f32
double = 3.14 // comptime_float → f64
double = 3.14159265359 // comptime_float → f64
When reassignment involves different concrete types, explicit conversions are required. This makes all computational costs visible and prevents accidental data loss.
mut small : i32 = 0
val large : i64 = 9223372036854775807 // Maximum i64 value
// ❌ Error: No automatic conversion between concrete types
// small = large // Error: use explicit conversion large:i32
// ✅ Explicit conversion with visible cost
small = large:i32 // Explicit: i64 → i32 conversion (potential data loss)
small = 9223372036854775807:i32 // Explicit: comptime_int → i32 conversion
mut single : f32 = 0.0
val double : f64 = 3.141592653589793 // More precise than f32 can represent
// ❌ Error: No automatic conversion between concrete types
// single = double // Error: use explicit conversion double:f32
// ✅ Explicit conversion with visible cost
single = double:f32 // Explicit: f64 → f32 conversion (precision loss)
single = 3.141592653589793:f32 // Explicit: comptime_float → f32 conversion
mut precise : f32 = 0.0
val big_int : i64 = 9223372036854775807
// ❌ Error: No automatic conversion between different concrete types
// precise = big_int // Error: use explicit conversion big_int:f32
// ✅ Explicit conversion with visible cost
precise = big_int:f32 // Explicit: i64 → f32 conversion (precision loss)
precise = 9223372036854775807:f32 // Explicit: comptime_int → f32 conversion
All concrete type conversions use the value:type syntax for maximum transparency:
The value:type syntax makes every conversion explicit and visible:
- Position: Immediately after the value being converted
- Purpose: Transparent type conversion with visible cost
- Scope: Applies to the value immediately before the colon
// ✅ Explicit conversion syntax
val widened : i64 = i32_val:i64 // i32 → i64 conversion (visible cost)
val converted : f32 = i64_val:f32 // i64 → f32 conversion (visible cost)
val narrowed : i32 = i64_val:i32 // i64 → i32 conversion (visible data loss)
// ❌ No automatic conversions
// val auto_wide : i64 = i32_val // Error: use i32_val:i64
// val auto_narrow : i32 = i64_val // Error: use i64_val:i32
- Cost Transparency: Every conversion is visible in the code
- No Hidden Behavior: No automatic conversions between concrete types
- Explicit Choice: Developer must consciously choose all conversions
- Uniform Syntax: Same
value:typepattern everywhere - Performance Clarity: Conversion costs are obvious
- Safety: Prevents accidental data loss through explicit conversion
- Simplicity: One rule, no exceptions
// Every conversion follows this exact pattern:
target_variable : target_type = source_value:target_type
// Examples across all contexts:
val converted : f32 = int_val:f32 // Variable declaration
mut counter : i32 = large_val:i32 // Reassignment
func process(x: i32) = big_val:i32 // Function argument
array[index] = float_val:i32 // Array assignment
Conversions work naturally in complex expressions:
// Conversions in expressions
val result : f64 = (int_val:f64 + float_val:f64) / 2.0:f64
val mixed : i32 = (a:i32 + b:i32) * c:i32
val complex : f32 = sqrt(x:f32 * x:f32 + y:f32 * y:f32)
// Function calls with explicit type annotations
val direct_call : i32 = compute_value() // Explicit type required for function calls
val formatted : string = format_number(value:f64) // Function argument conversion
// If function returns different type, variable annotation determines the type
val i64_result : i64 = get_i64_value() // Function returns i64, variable is i64
val converted : i32 = i64_result:i32 // Conversion: i64 → i32 (explicit cost visible)
Key Points:
value:typeis always a conversion operation- Conversions can be chained:
value:intermediate:final - Comptime types don't need conversion syntax (they adapt automatically)
- Same types don't need conversion syntax (identity is free)
- This pattern works everywhere in Hexen - no exceptions
Error messages for type mismatches follow a consistent pattern, providing clear guidance:
mut small : i32 = 0
val large : i64 = 9223372036854775807
// ❌ Error messages with guidance
// small = large
// Error: Cannot assign i64 to i32 variable
// Use explicit conversion: large:i32
// small = 3.14159
// Error: Cannot assign comptime_float to i32 variable
// Use explicit conversion: 3.14159:i32
// ✅ Following the guidance
small = large:i32 // Explicit conversion (potential data loss)
small = 3.14159:i32 // Explicit conversion (truncation)
- Cost Transparency: All conversion costs are visible in the code
- Type Safety: All type conversions are explicit and intentional
- Performance Clarity: No hidden conversions or unexpected costs
- Error Prevention: Accidental type mismatches caught at compile time
- Maintainability: Clear documentation of every conversion
- Predictability: Simple, consistent rules with no exceptions
- Ergonomic Literals: Comptime types adapt seamlessly for common cases
The undef system follows the same "cost transparency" principle:
// ❌ Implicit undef (ambiguous - no type info)
mut pending = undef // Error: Cannot infer type
// ✅ Explicit undef (clear - type specified)
mut pending : i32 = undef // OK: Type explicitly provided
mut config : string = undef // OK: Type explicitly provided
Uninitialized variables follow the same conversion rules once assigned:
mut value : i32 = undef
value = 42 // comptime_int adapts to i32 (ergonomic)
value = large_val:i32 // explicit conversion (visible cost)
value = 10 + 20 // comptime arithmetic adapts to i32 (ergonomic)
The undef keyword works differently with immutable and mutable variables:
val config : string = undef // ❌ Error: val + undef creates unusable variable
val result : i32 = undef // ❌ Error: val + undef creates unusable variable
// Later assignments would break immutability:
// config = "production" // ❌ Error: val variables cannot be reassigned
// result = compute_value() // ❌ Error: val variables cannot be reassigned
Why this is forbidden: val variables with undef create a contradiction:
valvariables can only be assigned once (at declaration)undefrequires a later assignment to become usable- This breaks the immutability contract
✅ Use Expression Blocks Instead: For complex initialization, use Hexen's expression blocks:
// ✅ Complex initialization with expression blocks
val config : string = {
if development_mode {
return "development"
} else {
return "production"
}
}
val result : i32 = {
// Setup work in statement block (scoped)
{
val temp_data : string = load_data() // Explicit type required for concrete values!
validate(temp_data) // Runtime function call
}
// Complex computation with concrete values
val base : i32 = expensive_computation() // Explicit type required for concrete values!
val factor : i32 = multiplier_factor() // Explicit type required for concrete values!
return base * factor // i32 * i32 → i32 (concrete arithmetic)
}
→ See UNIFIED_BLOCK_SYSTEM.md for complete details on expression blocks and complex initialization patterns
mut config : string = undef // ✅ OK: deferred initialization
mut result : i32 = undef // ✅ OK: deferred initialization
// Later assignments are allowed:
config = "production" // ✅ OK: first real assignment
result = compute_value() // ✅ OK: first real assignment
result = 42 // ✅ OK: subsequent reassignment
val+undef: Creates an unusable variable (cannot be assigned later)mut+undef: Enables proper deferred initialization patterns- Type Safety: Both require explicit type annotation (
undefalone is not allowed) - Consistency: Follows the mutability contract -
val= single assignment,mut= multiple assignments
Error messages follow the same pattern as undef errors, pointing to the same solution:
Type mismatch: variable 'x' declared as i32 but assigned value of type comptime_float
Mixed-type operation 'i32 + i64' requires explicit conversions
Use explicit conversions: 'val result = i32_val:i64 + i64_val' or 'val result = i32_val:f64 + i64_val:f64'
Variable 'pending' must have either explicit type or value
Cannot reassign immutable variable 'config': val variables can only be assigned once at declaration
Invalid usage: val variable 'result' declared with undef cannot be assigned later
Consider using 'mut result : i32 = undef' for deferred initialization
All errors suggest appropriate solutions: add explicit type annotation or choose correct mutability keyword.
func demonstrate_type_system() : void = {
// ===== Comptime Type Magic (Ergonomic Literals) =====
val flexible_int = 42 // comptime_int (preserved - flexible adaptation!)
val explicit_i64 : i64 = 42 // comptime_int → i64 (context-driven, no cost)
val as_float : f32 = 42 // comptime_int → f32 (context-driven, no cost)
val flexible_float = 3.14 // comptime_float (preserved - flexible adaptation!)
val single : f32 = 3.14 // comptime_float → f32 (context-driven, no cost)
// ===== Critical Difference: val vs mut Comptime Type Preservation =====
// ✅ val preserves comptime types (flexible adaptation)
val preserved_math = 42 + 100 * 5 // comptime_int (stays flexible!)
val as_different_i32 : i32 = preserved_math // SAME source → i32
val as_different_i64 : i64 = preserved_math // SAME source → i64
val as_different_f64 : f64 = preserved_math // SAME source → f64
// 🔴 mut cannot preserve comptime types (immediate resolution required)
mut counter : i32 = 42 + 100 * 5 // comptime_int → i32 (immediately resolved!)
// val cant_adapt : i64 = counter // ❌ Error: counter is concrete i32, needs counter:i64
val must_convert : i64 = counter:i64 // ✅ Explicit conversion required (no flexibility left)
// ===== Demonstrating Comptime Type Flexibility =====
// Same flexible variable used in different contexts!
val small_version : i32 = flexible_int // comptime_int → i32 (same source!)
val large_version : i64 = flexible_int // comptime_int → i64 (same source!)
val float_version : f64 = flexible_int // comptime_int → f64 (same source!)
// Same comptime arithmetic in different contexts
val math_expr = 42 + 100 * 5 // comptime_int (stays flexible!)
val as_i32 : i32 = math_expr // NOW resolves to i32
val as_f64 : f64 = math_expr // SAME expr resolves to f64
// ===== Explicit Concrete Conversions (Visible Costs) =====
val wide : i64 = i32_value:i64 // i32 → i64 (explicit conversion)
val precise : f64 = f32_value:f64 // f32 → f64 (explicit conversion)
val as_float : f32 = i32_value:f32 // i32 → f32 (explicit conversion)
val narrowed : i32 = i64_value:i32 // i64 → i32 (explicit, potential data loss)
// ===== Mutable Variables with Explicit Conversions =====
mut counter : i32 = 0 // Mutable integer
counter = 42 // ✅ OK: comptime_int adapts (no cost)
counter = large_value:i32 // ✅ OK: explicit conversion (visible cost)
mut accumulator : f64 = 0.0 // Mutable float
accumulator = 3.14 // ✅ OK: comptime_float adapts (no cost)
accumulator = counter:f64 // ✅ OK: explicit conversion (visible cost)
// ===== Mixed Type Operations (All Explicit) =====
val result1 : f64 = int_val:f64 + float_val:f64 // All conversions visible
val result2 : i32 = (big_val:i32 + small_val:i32) * multiplier:i32
val complex : f32 = sqrt(x:f32 * x:f32 + y:f32 * y:f32)
// ===== Complex Initialization with Expression Blocks =====
val complex_init : i32 = {
// Setup work (scoped)
{
val config : string = load_config()
validate_system(config)
}
// Complex computation - explicit types required for function call results
val base : i32 = expensive_computation()
val factor : i32 = get_dynamic_factor()
return base * factor
}
// ===== undef with Different Mutability =====
// val pending : i32 = undef // ❌ Error: val + undef creates unusable variable
mut pending : i32 = undef // ✅ OK: mut allows later assignment
pending = compute_value() // ✅ OK: if compute_value() returns i32
pending = other_value:i32 // ✅ OK: explicit conversion if needed
// val bad = undef // ❌ Error: no type context
// ===== Platform Index Type (usize) for Array Indexing =====
val array : [_]i32 = [10, 20, 30, 40, 50]
// ✅ Comptime literals adapt seamlessly (ergonomic!)
val elem1 : i32 = array[0] // comptime_int → usize (implicit)
val elem2 : i32 = array[2] // comptime_int → usize (implicit)
val slice1 : [_]i32 = array[1..4] // comptime_int → usize for bounds
// ✅ Explicit usize variables
val idx : usize = 3
val elem3 : i32 = array[idx] // usize (direct use)
// 🔧 User types require explicit conversion
val user_start : i32 = 1
val user_end : i32 = 4
val slice2 : [_]i32 = array[(user_start:usize)..(user_end:usize)] // Explicit conversions
// ❌ Float types forbidden for indexing
val float_idx : f32 = 2.5
// val bad_elem : i32 = array[float_idx] // ❌ Error: float cannot be index
// val bad_idx : usize = float_idx:usize // ❌ Error: float → usize forbidden
// ✅ If absolutely necessary, use explicit truncation chain
val truncated_idx : usize = (float_idx:i32):usize // Two-step: f32 → i32 → usize
val elem4 : i32 = array[truncated_idx] // Now valid (but very explicit!)
// 🔧 Converting usize back to user types (explicit)
val index_value : usize = 42
val as_i32 : i32 = index_value:i32 // usize → i32 (explicit)
val as_i64 : i64 = index_value:i64 // usize → i64 (explicit)
val as_f64 : f64 = index_value:f64 // usize → f64 (explicit)
}
// For binary operations examples, see BINARY_OPS.md
// For complete range system details, see RANGE_SYSTEM.md
- Ergonomic: Comptime literals adapt seamlessly (no casting for common cases)
- Predictable: Simple, consistent rules with no exceptions
- Transparent: All conversion costs are visible in the code
- Intentional: Every type conversion is an explicit developer choice
- Cost Transparency: Every conversion is visible in the code
- No Hidden Costs: No automatic conversions or unexpected operations
- Performance Predictable: Developers can easily reason about runtime costs
- Optimization Friendly: Compilers can optimize knowing all conversions are explicit
- Compile-time validation: All type compatibility checked at compile time
- No silent bugs: Type mismatches cause compilation errors with clear guidance
- Explicit data loss: Developers must consciously acknowledge potential data loss
- Clear intent: Every conversion documents the developer's intention
- Simple mental model: One rule for all conversions (
value:type) - Readable code: All type operations are visible in the source
- Easy debugging: No hidden conversions to trace through
- Consistent everywhere: Same philosophy extends to all language features