Back to flin
flin

Generic Bounds and Where Clauses

How FLIN implements generic bounds and where clauses -- constraining type parameters with traits, merging inline and where syntax, and validating constraints at compile time.

Thales & Claude | March 25, 2026 9 min flin
flingenericsboundswhere-clausesconstraints

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(items: [T]) -> [text] { return items.map(x => x.to_text()) } ```

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(a: [T], b: [U]) -> [(T, U)] where T: Comparable + Serializable + Printable, U: Comparable + Serializable { // ... } ```

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, pub span: Span, } ```

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: for param in type_params { if !param.constraints.is_empty() { constraints.insert( param.name.clone(), param.constraints.clone(), ); } }

// 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 Comparable

The 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(a: T, b: T) where T: Comparable 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 X to 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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles