Every programming language needs a way to group data. The question is how many ways, and what trade-offs each one makes.
FLIN has three: tuples, enums, and entity structs. Each serves a distinct purpose, and the boundaries between them are deliberate. Tuples group anonymous, ordered data. Enums define named alternatives. Entity structs define persistent records with named fields. Understanding why FLIN has exactly these three -- and not two, or four -- requires understanding what each one does that the others cannot.
Tuples: Anonymous, Ordered, Lightweight
Tuples were added in Session 142. The motivation was simple: sometimes you need to return two values from a function, and creating an entity for that is overkill.
// Tuple literal
point = (10, 20)
rgb = (255, 128, 0)
pair = ("Juste", 25)// Typed tuple coordinates: (int, int) = (10, 20) mixed: (text, int, bool) = ("hello", 42, true)
// Access by index x = point.0 // 10 y = point.1 // 20 ```
A tuple is a fixed-size collection of values that can have different types. The type (int, text) means "a pair where the first element is an int and the second is a text." Unlike a list, a tuple's length and element types are known at compile time.
Tuple Type Representation
In the AST, tuples are a new type variant:
pub enum Type {
// ... existing variants ...
Tuple(Vec<Type>), // (int, text, bool)
}And in the type checker:
pub enum FlinType {
// ... existing variants ...
Tuple(Vec<FlinType>),
}Type checking on tuples verifies element-by-element compatibility:
fn types_compatible(&self, expected: &FlinType, actual: &FlinType) -> bool {
match (expected, actual) {
(FlinType::Tuple(expected_elems), FlinType::Tuple(actual_elems)) => {
expected_elems.len() == actual_elems.len()
&& expected_elems.iter().zip(actual_elems.iter())
.all(|(e, a)| self.types_compatible(e, a))
}
// ...
}
}Both the length and every element type must match. (int, text) is not compatible with (text, int) -- order matters.
Tuple Destructuring
Tuples interact with destructuring naturally:
(x, y) = get_coordinates()
(name, age, active) = ("Juste", 25, true)// In function returns fn divide(a: int, b: int) -> (int, int) { quotient = a / b remainder = a % b return (quotient, remainder) }
(q, r) = divide(17, 5) // q = 3, r = 2 ```
The destructuring pattern (x, y) mirrors the tuple literal (10, 20). Left side and right side have the same shape. The compiler matches them by position and binds each variable.
When to Use Tuples vs Entities
Tuples are for anonymous, ephemeral data. Use them when: - Returning multiple values from a function - Grouping values temporarily in a computation - The data has no meaningful field names
Entities are for named, persistent data. Use them when: - The data has a domain meaning (User, Product, Order) - The fields have meaningful names (name, email, price) - The data will be stored in a database
The rule of thumb: if you would name the fields first, second, third, use a tuple. If you would name them name, age, active, use an entity.
Enums: Named Alternatives
FLIN's enums evolved across multiple sessions, from simple value enums to full tagged unions with generics. This section covers the base enum -- the foundation that tagged unions extend.
Simple Enums
A simple enum defines a set of named values:
enum Direction {
North,
South,
East,
West
}enum Status { Pending, Active, Suspended, Deleted } ```
Each variant is a distinct value. Direction.North is not equal to Direction.South. You cannot assign a Direction to a Status variable. The compiler treats them as completely separate types.
Enum Representation
In the AST:
pub struct EnumVariant {
pub name: String,
pub data_type: Option<Type>,
pub span: Span,
}Stmt::EnumDecl {
name: String,
type_params: Vec
For simple enums, every variant has data_type: None. The type checker registers the enum and its variants:
fn register_enum(&mut self, name: &str, variants: &[EnumVariant]) {
let variant_names: Vec<String> = variants.iter()
.map(|v| v.name.clone())
.collect();// Register the enum type self.type_env.insert( name.to_string(), FlinType::Enum { name: name.to_string(), type_params: vec![], variants: variant_names.iter() .map(|n| (n.clone(), None)) .collect(), }, );
// Register each variant as a value for variant in &variant_names { self.value_env.insert( format!("{}.{}", name, variant), FlinType::Enum { name: name.to_string(), type_params: vec![], variants: vec![(variant.clone(), None)], }, ); } } ```
Each variant is registered both as part of the enum type and as an individual value. This allows Direction.North to be used as a value and Direction to be used as a type.
Enum Methods
Enums can have associated methods:
enum Direction {
North,
South,
East,
West
}fn Direction.opposite() -> Direction { match self { North -> South South -> North East -> West West -> East } }
heading = Direction.North reverse = heading.opposite() // Direction.South ```
The match inside the method is exhaustive -- every variant is handled. If a new variant is added to the enum, the compiler will flag every method that does not handle it.
Matching on Enums
Pattern matching on simple enums checks the variant:
fn describe(dir: Direction) -> text {
match dir {
North -> "heading north"
South -> "heading south"
East -> "heading east"
West -> "heading west"
}
}The compiler verifies exhaustiveness: every variant must be covered. This is the fundamental safety guarantee of enums -- you cannot forget a case.
Entity Structs: Named, Persistent, Relational
Entities are FLIN's primary data structure. They serve as both types and database tables:
entity User {
name: text
email: text
age: int = 0
bio: text? = none
role: text = "user"
created: time = now
}This declaration creates:
- A type called User
- A database table called users
- CRUD operations: User.all, User.find(id), User.where(...), save, delete
- Field accessors: user.name, user.email, etc.
Entity Construction
Entities are constructed with named fields:
user = User {
name: "Juste",
email: "[email protected]",
age: 25
}The type checker validates construction:
fn check_entity_construction(
&mut self,
entity_name: &str,
fields: &[(String, Expr)],
) -> FlinType {
let entity_def = self.get_entity_def(entity_name);// Check all required fields are provided for field in &entity_def.fields { if field.default.is_none() && field.optional == false { let provided = fields.iter().any(|(name, _)| name == &field.name); if !provided { self.report_error(&format!( "missing required field '{}' in {} construction", field.name, entity_name )); } } }
// Check all provided fields exist and have correct types for (name, value) in fields { match entity_def.fields.iter().find(|f| &f.name == name) { Some(field_def) => { let value_type = self.infer_type(value); if !self.types_compatible(&field_def.field_type, &value_type) { self.report_error(&format!( "field '{}' expects {}, got {}", name, field_def.field_type, value_type )); } } None => { self.report_error(&format!( "unknown field '{}' in {}", name, entity_name )); } } }
FlinType::Entity(entity_name.to_string()) } ```
Three validations: required fields must be present, provided fields must exist, and field values must have the correct type.
Entity Relations
Entities can reference other entities:
entity User {
name: text
posts: [Post] // one-to-many
}entity Post { title: text content: text author: User // many-to-one } ```
The type checker resolves these references. user.posts has type [Post]. post.author has type User. post.author.name has type text. The chain of field accesses is type-checked at each step.
Entity vs Tuple: The Naming Test
The practical difference between entities and tuples is naming. Consider:
// Tuple -- anonymous, positional
point = (10, 20)
x = point.0 // what is 0? Width? Latitude? Column?// Entity -- named, self-documenting entity Point { x: int, y: int } point = Point { x: 10, y: 20 } x = point.x // unambiguous ```
The entity version is longer. It is also impossible to misunderstand. When someone reads point.x, they know exactly what they are accessing. When they read point.0, they need to check the tuple's definition to know what position 0 means.
The Three-Way Comparison
| Feature | Tuple | Enum | Entity |
|---|---|---|---|
| Named fields | No | No (variants are named) | Yes |
| Multiple types | Yes | Yes (each variant) | Yes |
| Persistent | No | No | Yes |
| Pattern matching | By position | By variant | By field name |
| Mutability | Immutable | Immutable | Mutable |
| Relations | No | No | Yes (one-to-many, etc.) |
| Database table | No | No | Yes |
Tuples are ephemeral groups. Enums are type-safe alternatives. Entities are persistent records. Each has a clear role, and using the right one for the right purpose makes FLIN programs clearer.
Combining All Three
Real FLIN programs use all three together:
enum Shape {
Circle(number),
Rectangle(number, number),
Point
}entity Drawing { name: text shapes: [Shape] origin: (int, int) created: time = now }
fn bounding_box(shape: Shape) -> (number, number) { match shape { Circle(r) -> (r 2, r 2) Rectangle(w, h) -> (w, h) Point -> (0, 0) } }
drawing = Drawing { name: "My Drawing", shapes: [Circle(5.0), Rectangle(10.0, 20.0), Point], origin: (0, 0) }
for shape in drawing.shapes { (width, height) = bounding_box(shape) print("Bounding box: " + text(width) + " x " + text(height)) } ```
The Shape enum defines alternatives (circle, rectangle, point). The Drawing entity defines a persistent record. The bounding_box function returns a tuple. The for loop destructures the tuple. All three data structures work together.
Implementation Across Sessions
The three data structures were implemented across multiple sessions:
- Entities: Part of FLIN's core from the earliest sessions. The
entitykeyword and its associated database operations were the first major feature. - Enums: Session 048 added simple enums. Session 145 extended them to tagged unions with generics.
- Tuples: Session 142 added tuple types, literals, and destructuring.
Each feature was designed with awareness of the others. Tuple destructuring uses the same Pattern enum as entity destructuring. Enum pattern matching uses the same match expression infrastructure as tuple and entity matching. The code generation for all three shares common patterns for value access and binding.
Design Principles
Three principles guided the design of FLIN's data structures:
Orthogonality. Each data structure does one thing. Tuples group. Enums alternate. Entities persist. There is no data structure that tries to do all three.
Composability. Data structures compose with each other. An entity can have a field of enum type. A tuple can contain entities. An enum variant can carry a tuple. Any combination is valid.
Progressive complexity. A beginner starts with entities -- the most common and most useful data structure. Tuples and enums are there when needed but not required for simple programs. The learning curve is gradual.
These principles reflect FLIN's broader philosophy: make the common case simple and the advanced case possible. Most programs need entities. Some need enums. Fewer need tuples. But when you need any of them, they are there, well-designed, and interoperable.
The next article covers type guards and runtime type narrowing -- the mechanism that makes working with these diverse data types safe at every point in the program.
---
This is Part 39 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: - [37] Destructuring Everywhere - [38] The Pipeline Operator: Functional Composition in FLIN - [39] Tuples, Enums, and Structs (you are here) - [40] Type Guards and Runtime Type Narrowing - [41] The Never Type and Exhaustiveness Checking