Back to flin
flin

Traits and Interfaces

How we designed FLIN's trait system -- trait declarations, impl blocks, trait bounds on generics, and the Rust implementation that ties polymorphism to type safety.

Thales & Claude | March 25, 2026 11 min flin
flintraitsinterfacespolymorphism

Generic types gave FLIN the ability to write functions that work with any type. But "any type" is too broad. A sorting function does not work with any type -- it works with types that can be compared. A serialization function does not work with any type -- it works with types that can be converted to text. A hashing function does not work with any type -- it works with types that have a hash implementation.

Traits are the mechanism that constrains generic types to the subset of types that actually support the operations a function needs.

Sessions 133 through 136 built FLIN's trait system from the ground up: trait declarations, impl blocks, trait bounds on generic type parameters, and the type checker infrastructure that validates constraints at compile time.

The Design

FLIN's trait system draws from Rust's trait system and TypeScript's interface system, but simplifies both for FLIN's audience.

A trait declares a set of method signatures that a type must implement:

trait Printable {
    fn to_text() -> text
}

trait Comparable { fn compare(other: Self) -> int }

trait Serializable { fn serialize() -> text fn deserialize(data: text) -> Self } ```

An impl block provides the implementation for a specific type:

entity User {
    name: text
    email: text
}

impl Printable for User { fn to_text() -> text { return name + " <" + email + ">" } }

impl Comparable for User { fn compare(other: User) -> int { return name.compare(other.name) } } ```

This separation -- declaration in one place, implementation in another -- is the key design decision. It means traits can be defined in one module and implemented in another. A library can define a trait, and user code can implement it for their own types.

Why Traits, Not Interfaces

TypeScript uses interfaces. Java uses interfaces. Why did FLIN choose traits?

The distinction matters. An interface in TypeScript is structural: any object with the right shape satisfies the interface, whether or not it declares that it does. A trait in FLIN is nominal: a type satisfies a trait only if there is an explicit impl block declaring the implementation.

We chose nominal traits for two reasons.

First, explicitness. When you see impl Comparable for User, you know exactly which types implement which traits. In a structural system, you must examine the shape of every type to determine compatibility. For FLIN's target audience -- developers who value clarity over cleverness -- explicit implementation is easier to understand.

Second, error messages. When a trait bound fails, the compiler can say "User does not implement Comparable -- add an impl Comparable for User block." With structural typing, the error would be "User is missing method compare(other: Self) -> int" -- which is technically correct but does not tell the developer what abstraction they failed to satisfy.

Trait Declaration in the AST

The trait declaration is a new statement variant:

pub enum Stmt {
    TraitDecl {
        name: String,
        type_params: Vec<TypeParam>,
        methods: Vec<TraitMethod>,
        span: Span,
    },
    ImplBlock {
        trait_name: String,
        target_type: Type,
        type_args: Vec<Type>,
        methods: Vec<Stmt>,
        span: Span,
    },
    // ...
}

pub struct TraitMethod { pub name: String, pub params: Vec, pub return_type: Option, pub default_body: Option, pub span: Span, } ```

A TraitMethod includes an optional default_body. Default implementations are methods that a trait provides out of the box, which implementors can override if needed:

trait Printable {
    fn to_text() -> text                    // required
    fn debug_text() -> text {               // default implementation
        return "[" + to_text() + "]"
    }
}

With a default body, implementing Printable only requires to_text(). The debug_text() method comes for free, using the default implementation.

Parsing Traits and Impl Blocks

The parser recognizes trait and impl as keywords and dispatches to dedicated parsing functions:

fn parse_trait_decl(&mut self) -> Result<Stmt, ParseError> {
    self.expect_keyword("trait")?;
    let name = self.expect_identifier()?;
    let type_params = self.parse_type_params()?;
    self.expect(&Token::LeftBrace)?;

let mut methods = vec![]; while !self.check(&Token::RightBrace) { methods.push(self.parse_trait_method()?); } self.expect(&Token::RightBrace)?;

Ok(Stmt::TraitDecl { name, type_params, methods, span: self.current_span() }) }

fn parse_impl_block(&mut self) -> Result { self.expect_keyword("impl")?; let trait_name = self.expect_identifier()?; self.expect_keyword("for")?; let target_type = self.parse_type()?; self.expect(&Token::LeftBrace)?;

let mut methods = vec![]; while !self.check(&Token::RightBrace) { methods.push(self.parse_fn_decl()?); } self.expect(&Token::RightBrace)?;

Ok(Stmt::ImplBlock { trait_name, target_type, type_args: vec![], methods, span: self.current_span() }) } ```

The parsing is straightforward because the syntax is regular. trait Name { methods } and impl Trait for Type { methods } follow the same brace-delimited block pattern as entities and functions.

Type Checker: Trait Registration

When the type checker encounters a trait declaration, it registers the trait in a trait registry:

struct TraitRegistry {
    traits: HashMap<String, TraitDef>,
    impls: HashMap<(String, String), ImplDef>,  // (trait_name, type_name) -> impl
}

struct TraitDef { name: String, type_params: Vec, methods: Vec, }

struct TraitMethodDef { name: String, params: Vec<(String, FlinType)>, return_type: FlinType, has_default: bool, } ```

When the type checker encounters an impl block, it validates three things:

1. The trait exists. 2. The target type exists. 3. Every required method is implemented with the correct signature.

fn check_impl_block(&mut self, impl_block: &Stmt) {
    let Stmt::ImplBlock { trait_name, target_type, methods, .. } = impl_block else {
        return;
    };

let trait_def = match self.trait_registry.get(trait_name) { Some(def) => def, None => { self.report_error(&format!("unknown trait: {}", trait_name)); return; } };

// Check all required methods are implemented for required in &trait_def.methods { if required.has_default { continue; // default methods are optional } let found = methods.iter().find(|m| m.name() == required.name); if found.is_none() { self.report_error(&format!( "impl {} for {} is missing method '{}'", trait_name, target_type, required.name )); } }

// Check method signatures match for method in methods { if let Some(required) = trait_def.methods.iter().find(|m| m.name == method.name()) { self.check_method_signature(method, required); } } } ```

This validation is the core value of traits. If a type claims to implement a trait, the compiler verifies that claim. No partial implementations. No missing methods. No signature mismatches.

Trait Bounds on Generics

The primary use of traits is to constrain generic type parameters. A generic function that sorts a list needs to know that the elements can be compared:

fn sort<T: Comparable>(items: [T]) -> [T] {
    // ... sorting logic that uses T.compare() ...
}

The syntax T: Comparable is a trait bound. It means "T can be any type, as long as that type implements the Comparable trait." The compiler enforces this at every call site:

sort([3, 1, 2])         // OK -- int implements Comparable
sort(["c", "a", "b"])   // OK -- text implements Comparable
sort([User{...}])        // ERROR -- User does not implement Comparable
                          // (unless there is an impl Comparable for User)

In the type checker, trait bounds are validated when generic functions are called:

fn check_generic_call(
    &mut self,
    func: &FnDef,
    type_args: &[FlinType],
    call_span: Span,
) {
    for (i, param) in func.type_params.iter().enumerate() {
        let concrete_type = &type_args[i];
        for bound in &param.constraints {
            if !self.type_implements_trait(concrete_type, bound) {
                self.report_error(&format!(
                    "type {} does not implement trait {}",
                    concrete_type, bound
                ));
            }
        }
    }
}

fn type_implements_trait(&self, flin_type: &FlinType, trait_name: &str) -> bool { let type_name = flin_type.display_name(); self.trait_registry.impls.contains_key(&( trait_name.to_string(), type_name, )) } ```

The implementation lookup is a simple hash map check. Does a (trait_name, type_name) pair exist in the impl registry? If yes, the type implements the trait. If no, the bound is violated.

Built-in Trait Implementations

FLIN's primitive types come with built-in trait implementations. These are registered in the trait registry during compiler initialization:

// Built-in implementations (implicit, not written by users)
impl Comparable for int { ... }
impl Comparable for number { ... }
impl Comparable for text { ... }

impl Printable for int { ... } impl Printable for number { ... } impl Printable for text { ... } impl Printable for bool { ... } ```

This means generic functions with Comparable bounds work out of the box with primitive types. Users only need to write impl blocks for their own entity types.

The Self Type

Traits use Self to refer to the implementing type:

trait Cloneable {
    fn clone() -> Self
}

impl Cloneable for User { fn clone() -> User { // Self becomes User return User { name: name, email: email } } } ```

In the type checker, Self is resolved to the concrete type when checking impl blocks:

fn resolve_self_type(&self, flin_type: &FlinType, self_type: &FlinType) -> FlinType {
    match flin_type {
        FlinType::SelfType => self_type.clone(),
        FlinType::List(inner) => {
            FlinType::List(Box::new(self.resolve_self_type(inner, self_type)))
        }
        FlinType::Optional(inner) => {
            FlinType::Optional(Box::new(self.resolve_self_type(inner, self_type)))
        }
        other => other.clone(),
    }
}

Multiple Trait Bounds

A type parameter can have multiple trait bounds:

fn sort_and_print<T: Comparable + Printable>(items: [T]) {
    sorted = sort(items)
    for item in sorted {
        print(item.to_text())
    }
}

The + syntax combines traits. The type must implement all listed traits. The type checker validates each bound independently:

for bound in &param.constraints {
    if !self.type_implements_trait(concrete_type, bound) {
        self.report_error(/* ... */);
    }
}

Multiple bounds are stored as a vector of constraint names on each type parameter. The loop checks each one.

Error Messages for Trait Violations

When a trait bound fails, the error message is specific and actionable:

error[E0010]: trait bound not satisfied
  --> app.flin:15:5
   |
15 |     sort([User{name: "Juste"}])
   |     ^^^^ User does not implement Comparable
   |
   = note: required by bound T: Comparable in fn sort<T: Comparable>
   = hint: add an implementation: impl Comparable for User { fn compare(other: User) -> int { ... } }

The error tells the developer what trait is missing, where it is required, and how to fix it. The hint includes the method signature that needs to be implemented. This level of detail is possible because traits are nominal -- the compiler knows exactly which trait is missing and what its methods are.

Design Decisions

Several design decisions shaped FLIN's trait system.

No trait inheritance. Traits cannot extend other traits. This keeps the system simple -- there is no diamond problem, no method resolution order, no super-trait requirements. If a function needs both Comparable and Printable, it uses T: Comparable + Printable.

No associated types (initially). Associated types -- types that are defined as part of a trait -- were deferred to a later session. The initial trait system focused on methods.

No orphan rule. In Rust, you can only implement a trait for a type if you own either the trait or the type. FLIN does not enforce this rule, because FLIN programs are typically single-author applications, not multi-crate ecosystems.

Impl blocks are statements. Impl blocks can appear anywhere statements can appear -- at the top level of a file, inside a block, even conditionally. This is more flexible than Rust's approach (where impl blocks must be at the module level) and aligns with FLIN's philosophy that the file is the component.

The Session Arc

The trait system was built across four sessions:

  • Session 133: Trait declaration syntax, parsing, and AST representation
  • Session 134: Impl blocks, parsing, and trait method validation
  • Session 135: Trait bounds on generic type parameters
  • Session 136: Built-in trait implementations, error messages, and the Never type interaction

Each session built on the previous one and maintained the full test suite throughout. By Session 136, FLIN had a complete trait system -- not as powerful as Rust's, but powerful enough for the patterns that FLIN programs actually use.

What Traits Enable

With traits in place, FLIN's standard library could express patterns that were previously impossible to type-check:

fn max<T: Comparable>(a: T, b: T) -> T {
    if a.compare(b) > 0 { return a }
    return b
}

fn to_text_list(items: [T]) -> [text] { return items.map(x => x.to_text()) }

fn find_min(items: [T]) -> T? { if items.len == 0 { return none } result = items[0] for item in items[1:] { if item.compare(result) < 0 { result = item } } return result } ```

Each of these functions is generic, type-safe, and works with any type that satisfies the trait bound. The compiler verifies at every call site that the concrete type has the required implementation. No runtime dispatch. No type errors at runtime.

Traits completed the third pillar of FLIN's type system. Primitives and inference provide the foundation. Union types and generics provide the expressiveness. Traits provide the constraints that make generics safe. Together, they form a type system that is simple enough for beginners and powerful enough for real applications.

The next article covers pattern matching -- the syntax that ties all of these type system features together into ergonomic code.

---

This is Part 34 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: - [32] Union Types and Type Narrowing - [33] Generic Types in FLIN - [34] Traits and Interfaces (you are here) - [35] Pattern Matching: From Switch to Match - [36] Tagged Unions and Algebraic Data Types

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles