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
// Generic function with multiple type parameters
fn map
// Generic type instantiation
value: Option
// 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 with T = int, E = text become Result.
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: Optiontest_parse_generic_type_instantiation_multiple -- result: Resulttest_parse_generic_type_nested -- data: Option<[int]>test_parse_generic_type_display -- formatting verificationtest_parse_generic_in_entity_field -- entity field with generic typetest_parse_non_generic_type_alias -- ensure non-generic aliases still worktest_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:
Feature Session Status Named Arguments 99 Complete Union Types 100 Complete Slicing 100 Complete Generic Types 101 Complete
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
Loading responses...
Related Articles
Thales & Claude sh0 34 Rules to Catch Deployment Mistakes Before They Happen
We built a pure-Rust static analysis engine with 34 rules across 8 categories to catch security issues, misconfigurations, and deployment mistakes before they reach production.
12 min Mar 25, 2026 ruststatic-analysissecuritycode-health +2Claude flin FLIN: The Language That Replaces 47 Technologies
One language for frontend, backend, database, and tooling. Built from scratch in Rust with 3,200+ tests. No npm. No Webpack. No framework fatigue.
4 min Mar 25, 2026 flinrustprogramming-languagecompiler +2Thales & Claude sh0 Auto-Detecting 19 Tech Stacks from Source Code
How sh0's build engine detects 19 tech stacks, generates production-grade Dockerfiles with multi-stage builds, and creates optimized build contexts -- all in pure Rust.
11 min Mar 25, 2026 ruststack-detectiondockerfilebuild-engine +2
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 This heuristic works because FLIN's style convention -- and indeed the convention of every language with generics -- is to write The span adjacency approach was the breakthrough of Session 101. Before this solution, we considered several alternatives: 1. Keyword-based disambiguation. Require Span adjacency was elegant because it required minimal code changes and aligned with existing formatting conventions. Generic types required two new variants in the AST: And two modifications to existing statements: The Parsing generic type declarations required a new function, 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 Parsing generic type instantiations -- // 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 The type checker gained two new variants in When the type checker encounters a generic function call, it performs type parameter substitution. If The substitution logic: This function walks the type structure recursively, replacing every Nested generics work naturally because the type argument parser is recursive: The parser sees Generic types can appear as entity field types: The type checker resolves the generic types when checking entity field declarations. Generic types needed proper display formatting for error messages: When the compiler reports an error involving generic types, it displays them in the same syntax the developer wrote: Session 101 added 12 new parser tests specifically for generic types: 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. With generic types, Phase 2 of FLIN's type system was complete: 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 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. Generic types unlocked a cascade of downstream features. Trait bounds ( 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 MatchOption, there is no space between Option and <. In < 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.Option without spaces. No one writes Option . The adjacency check is effectively a formatting requirement, and it is one that every developer already follows.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.AST Representation
pub enum Type {
// ... existing variants ...
TypeParam(String), // T, U, etc.
Generic { name: String, type_args: Vec<Type> }, // Option<int>, Result<T, E>
}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,
// ...
},
// ...
}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
parse_type_params:fn parse_type_params(&mut self) -> Result<Vec<String>, ParseError> {
if !self.match_token(&Token::GenericOpen) {
return Ok(vec![]);
}GenericOpen token follows, it collects all the type parameter names. Otherwise, it returns an empty vector -- the declaration is not generic.Option -- required extending the base type parser:fn parse_base_type(&mut self) -> Result<Type, ParseError> {
let name = self.expect_identifier()?;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 is a TypeParam, not a reference to some type called T.Type Checker Support
FlinType:pub enum FlinType {
// ... existing variants ...
TypeParam(String),
Generic { name: String, type_args: Vec<FlinType> },
}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.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(),
}
}TypeParam with its concrete substitution. Option with T = int becomes Option. [T] becomes [int]. Nested generics like Result with T = int, E = text become Result.Nested Generics
data: Option<[int]> = [1, 2, 3]
nested: Result<Option<int>, text> = "error"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
entity Container {
value: Option<int>
items: Result<[text], text>
}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" } // ERRORDisplay and 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, ">")
}
// ...
}
}
}Option, Result, [T]. This consistency between source code and error messages reduces the cognitive load of debugging type errors.The Test Suite
test_parse_generic_type_alias_single -- type Optiontest_parse_generic_type_alias_multiple -- type Resulttest_parse_generic_function_single -- fn identitytest_parse_generic_function_multiple -- fn maptest_parse_generic_type_instantiation -- value: Optiontest_parse_generic_type_instantiation_multiple -- result: Resulttest_parse_generic_type_nested -- data: Option<[int]>test_parse_generic_type_display -- formatting verificationtest_parse_generic_in_entity_field -- entity field with generic typetest_parse_non_generic_type_alias -- ensure non-generic aliases still worktest_parse_non_generic_function -- ensure non-generic functions still workPhase 2 Complete
Feature Session Status Named Arguments 99 Complete Union Types 100 Complete Slicing 100 Complete Generic Types 101 Complete Result, and all of them used the bidirectional type inference engine.The Broader Impact
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.Responses
Related Articles
34 Rules to Catch Deployment Mistakes Before They Happen
We built a pure-Rust static analysis engine with 34 rules across 8 categories to catch security issues, misconfigurations, and deployment mistakes before they reach production.
FLIN: The Language That Replaces 47 Technologies
One language for frontend, backend, database, and tooling. Built from scratch in Rust with 3,200+ tests. No npm. No Webpack. No framework fatigue.
Auto-Detecting 19 Tech Stacks from Source Code
How sh0's build engine detects 19 tech stacks, generates production-grade Dockerfiles with multi-stage builds, and creates optimized build contexts -- all in pure Rust.