Most programming languages force a choice. You can have safety, but you pay for it with type annotations on every line. Or you can have brevity, but you accept that entire categories of bugs will only surface at runtime, in production, at 3 AM.
FLIN refuses the trade-off.
When we sat down to design the type system for FLIN -- a language intended for developers who want to build full-stack applications in a single file -- we started from a question that sounds simple but carries enormous consequences: what is the minimum amount of type information a developer should have to write, given that the compiler can figure out the rest?
The answer became the foundation of everything in this article: types you rarely write, safety you always get.
The Design Principles
Four principles guided every type system decision. We wrote them on a whiteboard in Abidjan before writing a single line of Rust, and we referred back to them whenever a design question arose.
Inferred. Types are automatically determined from values. If you write count = 42, the compiler knows that is an int. You should not have to tell it.
Simple. A small set of clear, useful types. No distinction between i8, i16, i32, i64, u8, u16, u32, u64 like Rust. No String versus &str versus &'a str. One integer type. One floating-point type. One text type.
Safe. Catch errors at compile time, not runtime. If you try to add a number to a text value, the compiler should tell you immediately, not let the application crash when a user triggers that code path six months later.
Practical. Types that match real-world data. A money type that understands currencies. A time type with temporal keywords. A semantic text type that automatically generates AI embeddings.
These four principles created natural tension. Simplicity and expressiveness pull in opposite directions. Inference and safety require sophisticated analysis. The type system we built is the resolution of those tensions.
Primitive Types: The Foundation
FLIN has four primitive types, and they map directly to how developers think about data.
name = "Juste" // text -- UTF-8 string
count = 42 // int -- 64-bit signed integer
price = 99.99 // number -- 64-bit floating point
active = true // bool -- booleanNo type annotations. The compiler infers every one. The developer writes the value; the compiler determines the type.
The choice to have separate int and number types was deliberate. In JavaScript, everything is a number, and the language cannot tell you when you accidentally introduce floating-point arithmetic into what should be integer-only logic. In FLIN, 42 is an int and 3.14 is a number, and the compiler tracks the distinction through every operation.
The type hierarchy is minimal:
any
|
+--------+--------+
| | |
number text bool
|
intint is a subtype of number. This means you can pass an int anywhere a number is expected -- the coercion is automatic and safe. But you cannot pass a number where an int is expected without explicit conversion, because that truncation might lose data.
In the Rust implementation, the type hierarchy lives in the type checker:
fn types_compatible(&self, expected: &FlinType, actual: &FlinType) -> bool {
match (expected, actual) {
// Exact match
(a, b) if a == b => true,
// int is subtype of number
(FlinType::Number, FlinType::Int) => true,
// Any type accepts everything
(FlinType::Any, _) => true,
// Optional accepts the base type
(FlinType::Optional(inner), other) => self.types_compatible(inner, other),
// Everything else is incompatible
_ => false,
}
}This function is called thousands of times during type checking. Its simplicity is the point. Every compatibility question in FLIN reduces to a few clear rules.
Special Types: Beyond Primitives
FLIN's ambition goes beyond the standard primitive set. Three special types address domains that every application eventually encounters.
Time
The time type is a UTC timestamp with nanosecond precision. But what makes it distinctive is the set of temporal keywords:
created = now // current moment
birthday = "1990-05-15" // parsed from string
deadline = "2026-12-31T23:59:59Z"// Temporal keywords today // today at 00:00:00 UTC yesterday // yesterday at 00:00:00 tomorrow // tomorrow at 00:00:00 last_week // 7 days ago last_month // 30 days ago ```
These keywords are not functions. They are values. When you write today, the compiler knows the type is time, and the runtime evaluates it to the current date at midnight UTC. This matters because it means temporal expressions compose naturally: if deadline > now reads like English and type-checks as a boolean comparison between two time values.
Money
The money type was one of the most debated additions. Currency arithmetic is a minefield -- mixing CFA francs with US dollars in a single operation is a logical error that no amount of testing will reliably catch.
price = 1000 CFA
cost = 99.99 USDtotal = price + tax // OK if same currency // price + cost // ERROR: different currencies ```
The compiler enforces currency homogeneity. Two money values can only participate in arithmetic if they share a currency. This is a constraint that would require runtime validation in most languages but becomes a compile-time guarantee in FLIN.
Semantic Text
Perhaps the most forward-looking type in FLIN:
entity Product {
description: semantic text
}results = search "chair for back pain" in Product by description ```
A semantic text field is automatically embedded into a vector representation when saved. This enables vector similarity search without the developer ever touching an embedding model, a vector database, or a similarity function. The type system knows that a semantic text field supports the search operator; a plain text field does not.
Type Inference: The Compiler Does the Work
Type inference in FLIN is bidirectional. Information flows from values to variables (forward inference) and from context to expressions (backward inference).
Forward Inference
The straightforward case. A literal determines the type of the variable it is assigned to:
name = "Juste" // text
age = 25 // int
score = 98.5 // number
active = true // bool
items = [1, 2, 3] // [int]
user = User.find(id) // User?Every entity operation has a known return type. Entity.all returns [Entity]. Entity.find(id) returns Entity? -- an optional, because the entity might not exist. Entity.count returns int. The compiler propagates these types through every subsequent operation on the result.
Backward Inference
The more interesting case. Sometimes the context tells the compiler what type to expect:
items: [number] = [] // empty list typed by annotation
callback: (int) -> bool = x => x > 0 // lambda typed by annotationAn empty list has no elements to infer from. Without the : [number] annotation, the compiler would not know what type of elements the list will eventually hold. This is one of the rare cases where explicit types are necessary.
In the Rust implementation, backward inference flows through the type environment:
fn infer_type(&mut self, expr: &Expr) -> FlinType {
match expr {
Expr::ListLiteral { elements, .. } => {
if elements.is_empty() {
// Cannot infer -- needs annotation
FlinType::List(Box::new(FlinType::Unknown))
} else {
let elem_type = self.infer_type(&elements[0]);
FlinType::List(Box::new(elem_type))
}
}
Expr::EntityQuery { entity, method, .. } => {
match method.as_str() {
"all" => FlinType::List(Box::new(FlinType::Entity(entity.clone()))),
"find" => FlinType::Optional(Box::new(FlinType::Entity(entity.clone()))),
"count" => FlinType::Int,
_ => FlinType::Unknown,
}
}
// ... hundreds of inference rules
}
}The inference engine is the single largest component of the type checker. It handles every expression form in the language, propagating types through arithmetic, comparisons, member access, function calls, and entity operations.
Optional Types: Safety Without Ceremony
Null pointer exceptions are the billion-dollar mistake. FLIN eliminates them entirely through optional types.
Any type can be made optional with ?:
nickname: text? = none
age: int? = 25
user: User? = User.find(id)The critical design decision is what happens when you access a field on an optional value:
user: User? = User.find(id)
city = user.address.city // text? -- safe, returns none if user is noneNo null pointer exception. No crash. If user is none, the entire chain evaluates to none. This is automatic none propagation, and it eliminates an entire class of runtime errors.
The type checker enforces this. After a type check, the optional is narrowed:
if user {
// Inside this block: user is User (not User?)
print(user.name) // safe -- compiler knows user is not none
}This is type narrowing. The if condition on an optional value tells the compiler that inside the truthy branch, the value is guaranteed to be present. The compiler narrows the type from User? to User, and field access becomes safe without any additional checks.
Collection Types: Lists and Maps
FLIN has two collection types, and their syntax is designed to be immediately readable:
// Lists
numbers = [1, 2, 3, 4, 5] // [int]
names = ["Alice", "Bob"] // [text]
users: [User] = User.all // [User]// Maps ages = [ "Alice": 25, "Bob": 30, "Charlie": 35 ] // [text: int] ```
The type of a list is [ElementType]. The type of a map is [KeyType: ValueType]. Nested collections compose naturally: [[int]] is a list of lists of integers. [text: [User]] is a map from text to lists of users.
Higher-order operations on collections are typed end-to-end:
numbers = [1, 2, 3, 4, 5]
doubled = numbers.map(x => x * 2) // [int]
filtered = numbers.where(x => x > 2) // [int]
total = numbers.reduce(0, (a, b) => a + b) // intThe compiler infers that x in the lambda is int because numbers is [int]. It infers that doubled is [int] because mapping an int list with an int -> int function produces an [int]. Every link in the chain is verified at compile time.
Type Coercion: Controlled Flexibility
FLIN performs automatic type coercion in exactly four cases:
| From | To | Context |
|---|---|---|
int | number | Arithmetic with number |
int | text | String interpolation |
number | text | String interpolation |
| Any | bool | Conditions (truthy/falsy) |
These four rules are exhaustive. No other automatic coercion happens. If you try to add a text to an int, the compiler rejects it.
result = 5 + 3.14 // 8.14 -- int promoted to number
message = "Count: {count}" // int interpolated as text
if items { ... } // list coerced to bool (truthy if non-empty)The truthy/falsy rules are explicit and memorable:
| Type | Falsy | Truthy |
|---|---|---|
bool | false | true |
int | 0 | Any non-zero |
number | 0.0 | Any non-zero |
text | "" | Any non-empty |
[T] | [] | Any non-empty |
T? | none | Any value |
No surprises. No "0" == false weirdness. No implicit conversion chains that produce unexpected results.
Error Messages: Teaching, Not Scolding
A type system is only as good as its error messages. We spent significant effort making FLIN's error messages clear, specific, and actionable:
error[E0001]: type mismatch
--> app.flin:12:15
|
12 | count = "hello"
| ^^^^^^^ expected int, found text
|
= note: count was declared as int on line 5
= hint: use int("hello") to parse, or change count's typeEvery error message has three parts: what went wrong, where it was declared, and what to do about it. The hint suggests concrete fixes. This is especially important for FLIN's target audience -- developers who may not have years of experience with static type systems.
The error messages are generated by the type checker in Rust:
fn report_type_error(&self, expected: &FlinType, actual: &FlinType, span: Span) {
let msg = format!(
"expected {}, found {}",
expected.display_name(),
actual.display_name()
);
self.diagnostics.push(Diagnostic {
level: DiagnosticLevel::Error,
code: "E0001",
message: msg,
span,
notes: self.find_declaration_note(span),
hints: self.suggest_fix(expected, actual),
});
}The suggest_fix function examines the expected and actual types and generates specific suggestions. If you assigned a text to an int, it suggests int("hello"). If you used the wrong field name, it suggests the closest match. If you forgot to unwrap an optional, it suggests adding an if check.
Entity Types: The Bridge to Data
Entity declarations create new types that bridge the gap between application logic and persistent data:
entity User {
name: text
email: text
age: int = 0
bio: text? = none
created: time = now
}After this declaration, User is a first-class type. You can declare variables of type User, create lists of type [User], make optionals of type User?, and pass User values to functions. The type checker enforces field types on construction:
User { name: "Juste", email: "[email protected]" } // OK
User { name: 42 } // ERROR: name must be text
User { } // ERROR: name is required
User { name: "Juste", unknown: "x" } // ERROR: unknown fieldEvery field rule is checked at compile time. Required fields must be provided. Optional fields have defaults. Unknown fields are rejected. The entity is always valid when it reaches the database.
The Implementation in Rust
The entire type system is implemented in approximately 4,000 lines of Rust across three files:
src/typechecker/types.rs-- theFlinTypeenum and type operationssrc/typechecker/checker.rs-- the type checking pass over the ASTsrc/typechecker/import_binding.rs-- type resolution across modules
The FlinType enum is the core data structure:
pub enum FlinType {
Int,
Number,
Text,
Bool,
Time,
File,
Money,
List(Box<FlinType>),
Map(Box<FlinType>, Box<FlinType>),
Optional(Box<FlinType>),
Entity(String),
Union(Vec<FlinType>),
TypeParam(String),
Generic { name: String, type_args: Vec<FlinType> },
Enum { name: String, type_params: Vec<String>, variants: Vec<(String, Option<Box<FlinType>>)> },
Never,
Any,
Unknown,
}Every type in the language is a variant of this enum. The type checker walks the AST, infers types for every expression, checks compatibility at every assignment and function call, and emits diagnostics for every violation.
What Came Next
The type system overview covers the foundation. But FLIN's type system grew far beyond primitives and inference. Union types let a value be one of several types. Generic types enable polymorphic functions and containers. Tagged unions bring algebraic data types to a language designed for simplicity. Pattern matching makes working with all of these types safe and expressive.
Each of those features has its own story, its own design decisions, and its own implementation challenges. The next article covers the first major extension: union types and type narrowing.
---
This is Part 31 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 (you are here) - [32] Union Types and Type Narrowing - [33] Generic Types in FLIN - [34] Traits and Interfaces - [35] Pattern Matching: From Switch to Match