Back to flin
flin

Generic Types in FLIN

How we implemented generic types in FLIN -- type parameters, generic functions, generic type aliases, and the lexer trick that distinguishes Option<T> from <div>.

Thales & Claude | March 25, 2026 10 min flin
flingenericstype-parameterspolymorphism

Session 101 completed Phase 2 of FLIN's type system. The final feature: generic types.

Generics are the feature that separates a type system that can describe concrete data from one that can describe patterns of data. Without generics, you can write a function that sorts a list of integers. With generics, you can write a function that sorts a list of anything -- and the compiler still verifies that "anything" is used consistently.

For FLIN, generics were not a luxury. They were a necessity. The language has Option for optional values, Result for error handling, and collection operations like map and where that transform one type into another. All of these require generic types to be type-safe.

But FLIN is also a language with HTML-like view syntax. And angle brackets -- the universal generic syntax -- are also the universal HTML tag syntax. This collision created the single most interesting implementation challenge in the entire type system.

The Syntax

FLIN's generic syntax follows the convention established by Java, C#, TypeScript, and Rust:

// Generic type alias
type Option<T> = T?
type Result<T, E> = T | E

// Generic function fn identity(value: T) -> T { return value }

// Generic function with multiple type parameters fn map(list: [T], f: (T) -> U) -> [U] { // ... }

// Generic type instantiation value: Option = 42 result: Result = "error"

// Nested generics data: Option<[int]> = [1, 2, 3] ```

The angle bracket syntax was the only serious option. Alternatives like Option[T] (Scala) or Option(T) would have collided with list indexing and function calls respectively. Square brackets in FLIN already mean lists. Parentheses already mean function application. Angle brackets were the remaining delimiter pair.

Which brought us to the problem.

The Lexer Problem: versus

FLIN uses HTML-like syntax for views. A component looks like this:

count = 0
<button click={count++}>{count}</button>

And a generic type looks like this:

type Option<T> = T?

The lexer sees < and must decide: is this the start of a generic type argument list, or the start of an HTML tag? In most languages, this ambiguity does not exist because there is no HTML syntax. In FLIN, it is the central parsing challenge.

The solution was span adjacency. When the lexer encounters <, it checks whether the preceding token (an identifier) is immediately adjacent -- no whitespace between the identifier and the <:

// In the scanner
fn scan_less_than(&mut self) -> Token {
    let start_pos = self.current_position();

// Check if previous token is an identifier immediately adjacent if let Some(prev) = &self.previous_token { if prev.kind.is_identifier() && prev.span.end.offset == start_pos.offset { // No whitespace: this is a generic bracket return Token::GenericOpen; } }

// Whitespace present: this is a less-than or HTML tag Token::LessThan } ```

The key insight: in Option, there is no space between Option and <. In

, the < either starts a line or has whitespace before it. The lexer checks whether the < is immediately adjacent to the preceding identifier. If yes, it is a generic bracket. If no, it is HTML or comparison.

This heuristic works because FLIN's style convention -- and indeed the convention of every language with generics -- is to write Option without spaces. No one writes Option . The adjacency check is effectively a formatting requirement, and it is one that every developer already follows.

The span adjacency approach was the breakthrough of Session 101. Before this solution, we considered several alternatives:

1. Keyword-based disambiguation. Require generic keyword before type parameters. Rejected because it adds verbosity to every generic usage. 2. Context-dependent lexing. Track parser state in the lexer. Rejected because it couples the two phases and makes the lexer non-trivial. 3. Different delimiters. Use [T] or ::. Rejected because they collide with existing syntax or look alien.

Span adjacency was elegant because it required minimal code changes and aligned with existing formatting conventions.

AST Representation

Generic types required two new variants in the AST:

pub enum Type {
    // ... existing variants ...
    TypeParam(String),                          // T, U, etc.
    Generic { name: String, type_args: Vec<Type> },  // Option<int>, Result<T, E>
}

And two modifications to existing statements:

pub enum Stmt {
    TypeDecl {
        name: String,
        type_params: Vec<String>,   // NEW: <T, U, ...>
        value: Type,
        // ...
    },
    FnDecl {
        name: String,
        type_params: Vec<String>,   // NEW: <T, U, ...>
        params: Vec<Param>,
        return_type: Option<Type>,
        body: Block,
        // ...
    },
    // ...
}

The type_params field on both TypeDecl and FnDecl carries the declared type parameters. These parameters are then in scope when parsing the body of the declaration.

Parsing Generic Declarations

Parsing generic type declarations required a new function, parse_type_params:

fn parse_type_params(&mut self) -> Result<Vec<String>, ParseError> {
    if !self.match_token(&Token::GenericOpen) {
        return Ok(vec![]);
    }

let mut params = vec![]; loop { let name = self.expect_identifier()?; params.push(name); if !self.match_token(&Token::Comma) { break; } } self.expect(&Token::GreaterThan)?; Ok(params) } ```

This function is called after parsing the name of a type alias or function. If a GenericOpen token follows, it collects all the type parameter names. Otherwise, it returns an empty vector -- the declaration is not generic.

Parsing generic type instantiations -- Option -- required extending the base type parser:

fn parse_base_type(&mut self) -> Result<Type, ParseError> {
    let name = self.expect_identifier()?;

// Check for type arguments if self.check(&Token::GenericOpen) { let type_args = self.parse_type_arguments()?; Ok(Type::Generic { name, type_args }) } else if self.type_params_in_scope.contains(&name) { // This is a type parameter reference Ok(Type::TypeParam(name)) } else { Ok(Type::Named(name)) } } ```

The type_params_in_scope check is critical. When parsing inside a generic function or type, the parser knows which names are type parameters. T inside fn identity(value: T) is a TypeParam, not a reference to some type called T.

Type Checker Support

The type checker gained two new variants in FlinType:

pub enum FlinType {
    // ... existing variants ...
    TypeParam(String),
    Generic { name: String, type_args: Vec<FlinType> },
}

When the type checker encounters a generic function call, it performs type parameter substitution. If identity is called with an int argument, the type checker substitutes T = int throughout the function signature and verifies that the return type is also int.

The substitution logic:

fn substitute_type_params(
    &self,
    flin_type: &FlinType,
    substitutions: &HashMap<String, FlinType>,
) -> FlinType {
    match flin_type {
        FlinType::TypeParam(name) => {
            substitutions.get(name).cloned().unwrap_or(FlinType::Any)
        }
        FlinType::List(inner) => {
            FlinType::List(Box::new(self.substitute_type_params(inner, substitutions)))
        }
        FlinType::Optional(inner) => {
            FlinType::Optional(Box::new(self.substitute_type_params(inner, substitutions)))
        }
        FlinType::Generic { name, type_args } => {
            let resolved_args: Vec<FlinType> = type_args
                .iter()
                .map(|arg| self.substitute_type_params(arg, substitutions))
                .collect();
            FlinType::Generic { name: name.clone(), type_args: resolved_args }
        }
        other => other.clone(),
    }
}

This function walks the type structure recursively, replacing every TypeParam with its concrete substitution. Option with T = int becomes Option. [T] becomes [int]. Nested generics like Result, E> with T = int, E = text become Result, text>.

Nested Generics

Nested generics work naturally because the type argument parser is recursive:

data: Option<[int]> = [1, 2, 3]
nested: Result<Option<int>, text> = "error"

The parser sees Option, then <, then parses [int] as a type (a list of int), then sees >. The result is Generic { name: "Option", type_args: [List(Int)] }. Nesting to arbitrary depth is supported because parse_type_arguments calls parse_type, which can itself encounter and parse generic types.

Entity Fields with Generics

Generic types can appear as entity field types:

entity Container {
    value: Option<int>
    items: Result<[text], text>
}

The type checker resolves the generic types when checking entity field declarations. Option is resolved to a concrete optional integer type. This ensures that entity construction is type-safe:

Container { value: 42, items: ["a", "b"] }  // OK
Container { value: "not an int" }             // ERROR

Display and Error Messages

Generic types needed proper display formatting for error messages:

impl fmt::Display for Type {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Type::TypeParam(name) => write!(f, "{}", name),
            Type::Generic { name, type_args } => {
                write!(f, "{}<", name)?;
                for (i, arg) in type_args.iter().enumerate() {
                    if i > 0 { write!(f, ", ")?; }
                    write!(f, "{}", arg)?;
                }
                write!(f, ">")
            }
            // ...
        }
    }
}

When the compiler reports an error involving generic types, it displays them in the same syntax the developer wrote: Option, Result, [T]. This consistency between source code and error messages reduces the cognitive load of debugging type errors.

The Test Suite

Session 101 added 12 new parser tests specifically for generic types:

  • test_parse_generic_type_alias_single -- type Option = T?
  • test_parse_generic_type_alias_multiple -- type Result = T | E
  • test_parse_generic_function_single -- fn identity(value: T) -> T
  • test_parse_generic_function_multiple -- fn map(list: [T], f: (T) -> U) -> [U]
  • test_parse_generic_type_instantiation -- value: Option
  • test_parse_generic_type_instantiation_multiple -- result: Result
  • test_parse_generic_type_nested -- data: Option<[int]>
  • test_parse_generic_type_display -- formatting verification
  • test_parse_generic_in_entity_field -- entity field with generic type
  • test_parse_non_generic_type_alias -- ensure non-generic aliases still work
  • test_parse_non_generic_function -- ensure non-generic functions still work

The last two tests are as important as the first nine. Regression testing ensures that adding generics does not break the parsing of non-generic code. With 1,059 library tests and 93 integration tests all passing, we had confidence that the feature was additive -- it extended the language without breaking anything.

Phase 2 Complete

With generic types, Phase 2 of FLIN's type system was complete:

FeatureSessionStatus
Named Arguments99Complete
Union Types100Complete
Slicing100Complete
Generic Types101Complete

Four features across three sessions. Each one built on the infrastructure of the previous: union types used the type compatibility checker, generics used the union type infrastructure for Result = T | E, and all of them used the bidirectional type inference engine.

This layered construction is not accidental. It is the product of designing the type system as a coherent whole before implementing any individual feature. We knew from the beginning that union types would need generic type parameters, that generic types would need union type members, and that both would need type narrowing. Building them in sequence -- union types first, then generics -- meant each feature could assume the existence of the previous one.

The Broader Impact

Generic types unlocked a cascade of downstream features. Trait bounds (where T: Comparable) required generic type parameters as the thing being bounded. Pattern matching on generic enums required generic type instantiation. The standard library's collection operations (map, where, reduce) all needed generic function signatures.

Without Session 101, none of those features would have been possible in a type-safe way. Generics are the foundation on which the entire advanced type system rests.

The next article covers traits and interfaces -- the mechanism by which FLIN constrains what a generic type parameter can do.

---

This is Part 33 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: - [31] FLIN's Type System: Inferred, Expressive, Safe - [32] Union Types and Type Narrowing - [33] Generic Types in FLIN (you are here) - [34] Traits and Interfaces - [35] Pattern Matching: From Switch to Match

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles