Generic types without bounds are like promises without guarantees. A function that claims to work with "any type T" can only do things that are valid for literally every type: assign it, pass it around, maybe check if it is none. It cannot compare values, cannot convert them to text, cannot call any methods on them.
Bounds constrain a generic type parameter to types that satisfy specific requirements. T: Comparable means "any type T, as long as T implements the Comparable trait." With this bound, the function can call comparison methods on T values. Without it, the compiler rejects the comparison.
FLIN supports two syntaxes for bounds: inline bounds and where clauses. Session 144 implemented both, and Session 150 made the compiler actually validate them.
Inline Bounds
The inline syntax places bounds directly on the type parameter:
fn max<T: Comparable>(a: T, b: T) -> T {
if a > b { return a }
return b
}fn to_strings
The bound T: Comparable appears in the angle brackets, immediately after the type parameter name. This syntax is concise and works well for simple constraints.
Multiple bounds use +:
fn sort_and_display<T: Comparable + Printable>(items: [T]) -> [text] {
sorted = sort(items)
return sorted.map(x => x.to_text())
}T: Comparable + Printable requires that T implements both traits.
Where Clauses
For complex constraints, the where clause provides a more readable alternative:
fn complex_operation<T, U>(input: T, transformer: (T) -> U) -> [U]
where T: Serializable + Comparable,
U: Printable
{
// ...
}The where clause comes after the parameter list and before the function body. Each constraint is on its own line (by convention), making complex signatures readable.
Where clauses are especially valuable when: - There are multiple type parameters with multiple bounds - The bounds reference complex types - The inline syntax would make the signature hard to read
// Hard to read with inline bounds:
fn merge<T: Comparable + Serializable + Printable, U: Comparable + Serializable>(a: [T], b: [U]) -> [(T, U)] { ... }// Clear with where clause:
fn merge
The AST Representation
Type parameters with constraints are represented using the TypeParam struct:
pub struct TypeParam {
pub name: String,
pub constraints: Vec<String>, // trait names from inline bounds
pub span: Span,
}Where clauses are a separate field on function declarations:
Stmt::FnDecl {
name: String,
type_params: Vec<TypeParam>,
params: Vec<Param>,
return_type: Option<Type>,
where_clauses: Vec<WhereClause>, // where T: Trait
body: Block,
span: Span,
}pub struct WhereClause {
pub type_param: String,
pub bounds: Vec
This dual representation -- constraints on TypeParam and on WhereClause -- reflects the two syntaxes. The type checker merges them before validation.
Parsing Where Clauses
The parser recognizes the where keyword after the parameter list:
fn parse_where_clauses(&mut self) -> Result<Vec<WhereClause>, ParseError> {
if !self.check_keyword("where") {
return Ok(vec![]);
}
self.advance(); // consume "where"let mut clauses = vec![]; loop { let type_param = self.expect_identifier()?; self.expect(&Token::Colon)?;
let mut bounds = vec![]; loop { bounds.push(self.expect_identifier()?); if !self.match_token(&Token::Plus) { break; } }
clauses.push(WhereClause { type_param, bounds, span: self.current_span(), });
if !self.match_token(&Token::Comma) { break; } }
Ok(clauses) } ```
The parser collects each TypeParam: Bound1 + Bound2 entry, separated by commas. The resulting vector is attached to the function declaration.
Constraint Merging
The type checker merges inline bounds and where clause bounds into a single constraint map:
fn merge_constraints(
&self,
type_params: &[TypeParam],
where_clauses: &[WhereClause],
) -> HashMap<String, Vec<String>> {
let mut constraints: HashMap<String, Vec<String>> = HashMap::new(); // Collect inline constraints:
// Merge where clause constraints: where T: Serializable for clause in where_clauses { let entry = constraints .entry(clause.type_param.clone()) .or_insert_with(Vec::new);
for bound in &clause.bounds { if !entry.contains(bound) { entry.push(bound.clone()); } } }
constraints } ```
If a type parameter has both inline bounds and where clause bounds, they are combined:
fn example<T: Comparable>(value: T) where T: Printable {
// T must implement both Comparable AND Printable
}The merged constraint map for T is ["Comparable", "Printable"]. Both are validated at call sites.
Constraint Validation at Call Sites
Before Session 150, constraints were parsed but not validated. A developer could write T: Comparable and then call the function with a type that does not implement Comparable, and the compiler would not complain. Session 150 fixed this.
When the type checker encounters a call to a generic function, it:
1. Infers the concrete types for each type parameter from the arguments 2. Looks up the merged constraints for each type parameter 3. Checks that the concrete type satisfies each constraint
fn check_generic_function_call(
&mut self,
func: &FnDef,
args: &[Expr],
span: Span,
) -> FlinType {
// Step 1: Infer type arguments from arguments
let type_args = self.infer_type_args(func, args);// Step 2: Merge constraints let constraints = self.merge_constraints( &func.type_params, &func.where_clauses, );
// Step 3: Validate each constraint for (i, param) in func.type_params.iter().enumerate() { let concrete_type = &type_args[i]; if let Some(bounds) = constraints.get(¶m.name) { for bound in bounds { if !self.type_satisfies_trait(concrete_type, bound) { self.diagnostics.push(Diagnostic { level: DiagnosticLevel::Error, code: "E0010", message: format!( "type {} does not satisfy bound {}: {}", concrete_type.display_name(), param.name, bound ), span, notes: vec![format!( "required by constraint {} on {} in fn {}", bound, param.name, func.name )], hints: vec![format!( "add an implementation: impl {} for {} {{ ... }}", bound, concrete_type.display_name() )], }); } } } }
// Step 4: Substitute type args in return type self.substitute_type_params(&func.return_type, &type_args) } ```
The error message includes the specific bound that failed, which function requires it, and how to fix it. This level of detail is possible because constraints are explicit and named.
Built-in Trait Satisfaction
FLIN's primitive types automatically satisfy certain traits:
fn type_satisfies_trait(&self, flin_type: &FlinType, trait_name: &str) -> bool {
// Check explicit implementations first
if self.trait_registry.has_impl(trait_name, flin_type) {
return true;
}// Check built-in implementations match (flin_type, trait_name) { (FlinType::Int, "Comparable") => true, (FlinType::Number, "Comparable") => true, (FlinType::Text, "Comparable") => true, (FlinType::Int, "Printable") => true, (FlinType::Number, "Printable") => true, (FlinType::Text, "Printable") => true, (FlinType::Bool, "Printable") => true, (FlinType::Int, "Numeric") => true, (FlinType::Number, "Numeric") => true, _ => false, } } ```
The function first checks the trait registry for explicit impl blocks. Then it falls back to built-in trait satisfaction for primitive types. This means max(5, 3) works out of the box because int satisfies Comparable built-in.
Enum Bounds
Bounds also work on generic enums:
enum Container<T: Comparable> {
Empty,
Single(T),
Multiple([T])
}When constructing a Container, the type argument must satisfy Comparable:
Container.Single(42) // OK -- int satisfies Comparable
Container.Multiple(["a", "b"]) // OK -- text satisfies ComparableThe same validation logic applies. The type checker infers T = int from the argument and checks the Comparable bound.
Complex Constraint Patterns
Multiple Parameters with Independent Bounds
fn zip_with<T, U, V>(
list_a: [T],
list_b: [U],
combine: (T, U) -> V
) -> [V]
where T: Comparable,
V: Printable
{
// T is Comparable, V is Printable, U is unconstrained
}Each type parameter has its own independent constraints. The compiler validates each one separately against its concrete type.
Bounds on Entity Type Parameters
fn find_best<T: Comparable>(items: [T]) -> T? {
if items.len == 0 { return none }
best = items[0]
for item in items[1:] {
if item > best {
best = item
}
}
return best
}The > operator is valid because T: Comparable. Without the bound, the compiler would reject the comparison with "cannot compare values of unknown type T."
Nested Generic Bounds
fn flatten_and_sort<T: Comparable>(nested: [[T]]) -> [T] {
flat: [T] = []
for inner in nested {
flat = flat + inner
}
return sort(flat)
}The bound on T propagates through the nested structure. sort requires Comparable, and T: Comparable satisfies that requirement.
Test Suite
Session 150 added four specific tests for where clause validation:
1. test_e2e_where_clause_valid_constraint -- fn max called with int
2. test_e2e_where_clause_numeric_constraint -- where clause with Numeric trait
3. test_e2e_where_clause_multi_constraint_valid -- multiple bounds on same parameter
4. test_e2e_where_clause_merge_with_inline -- inline combined with where T: Printable
These tests verify both the positive case (constraint satisfied) and the interaction between the two syntaxes.
The Design Philosophy
Bounds and where clauses represent a particular philosophy about generic programming: constraints should be explicit and compiler-verified.
Some languages (like TypeScript) use structural typing for generics -- if a value has the right shape, it works. FLIN uses nominal bounds -- a type must explicitly declare (via impl) that it satisfies a trait. This means:
- Error messages name the specific trait that is missing
- Developers can search for
impl Comparable for Xto find all comparable types - Adding a trait to a type is an intentional act, not an accidental shape match
This explicitness costs a small amount of ceremony (writing impl blocks) but buys a large amount of clarity. When a generic function requires T: Comparable, the developer knows exactly what T needs to provide. When the constraint fails, the error message tells them exactly what to implement.
For FLIN's audience -- developers building applications, not library authors writing maximally generic code -- this trade-off is correct. Most generic code in FLIN uses a small number of well-known traits (Comparable, Printable, Serializable). The bounds are predictable, the constraints are familiar, and the error messages are actionable.
---
This is Part 42 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and implemented a programming language from scratch.
Series Navigation: - [40] Type Guards and Runtime Type Narrowing - [41] The Never Type and Exhaustiveness Checking - [42] Generic Bounds and Where Clauses (you are here) - [43] While-Let Loops and Break With Value - [44] Labeled Loops and Or-Patterns