Back to flin
flin

Union Types and Type Narrowing

How we implemented union types in FLIN -- the int | text | bool syntax, type narrowing through control flow, and the Rust compiler infrastructure that makes it all work.

Thales & Claude | March 25, 2026 11 min flin
flinunion-typestype-narrowingtype-system

Session 100 was a milestone. Not just because it was a round number -- though Thales did note the symmetry -- but because it delivered the feature that would fundamentally change how FLIN programs handle heterogeneous data: union types.

Before union types, every variable in FLIN had exactly one type. A value was an int, or it was a text, or it was a bool. If a function needed to return different types depending on its input, you had two options: use any and lose all type safety, or restructure your code to avoid the need entirely. Neither was satisfactory.

Union types solved this cleanly. A value of type int | text can be an integer or a string, and the compiler tracks which one it is through every branch of your code. This is type narrowing, and it is the feature that transforms union types from a syntactic convenience into a safety mechanism.

The Syntax Decision

The first design question was syntax. TypeScript uses number | string. Rust uses enum variants. Python uses Union[int, str] or the newer int | str. We chose the pipe syntax:

value: int | text = 42
flexible: int | text | bool = true

The pipe was the natural choice for three reasons. It reads like English ("int or text"). It is familiar to TypeScript developers, who are a large part of FLIN's target audience. And it composes without nesting -- int | text | bool | number is flat and readable, unlike Union[int, Union[text, Union[bool, number]]].

But syntax is the easy part. The hard part is what happens inside the compiler.

AST Representation

The first implementation decision was how to represent union types in the abstract syntax tree. We added a single variant to the Type enum:

pub enum Type {
    // ... existing variants ...
    Union(Vec<Type>),
}

A Vec rather than a pair of types. This was important. A binary representation -- Union(Box, Box) -- would require nested unions for three or more members: Union(Int, Union(Text, Bool)). The flat vector representation means int | text | bool is Union(vec![Int, Text, Bool]), which is simpler to work with in every subsequent pass.

The parser extension was straightforward. After parsing a base type, we check for a | token and continue collecting types:

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

if self.check(&Token::Pipe) { let mut types = vec![first]; while self.match_token(&Token::Pipe) { types.push(self.parse_base_type()?); } Ok(Type::Union(types)) } else { Ok(first) } } ```

Seven lines of parser code. The simplicity is deceptive -- the real complexity lives in the type checker.

Type Checking Union Types

When the type checker encounters a union type, it must answer two questions. First: is a given value assignable to this union? Second: what operations are valid on a union-typed variable?

Assignment Compatibility

A value is assignable to a union if it matches any member of the union:

value: int | text = 42       // 42 is int, int is in union -- OK
value = "hello"               // "hello" is text, text is in union -- OK
value = true                  // true is bool, bool is NOT in union -- ERROR

The implementation uses the existing types_compatible function, checking each union member:

fn types_compatible(&self, expected: &FlinType, actual: &FlinType) -> bool {
    match (expected, actual) {
        // A union accepts any of its members
        (FlinType::Union(members), actual) => {
            members.iter().any(|m| self.types_compatible(m, actual))
        }
        // A value matches a union if it matches any member
        (expected, FlinType::Union(members)) => {
            members.iter().any(|m| self.types_compatible(expected, m))
        }
        // ... other rules ...
    }
}

The first arm handles assignment: can this value be stored in a union-typed variable? The second arm handles the reverse: can a union-typed value be used where a specific type is expected? Both check every member of the union.

Unification

Type unification -- determining the result type of an operation involving union types -- was the subtler challenge. When you add two values, the result type depends on the operand types. What is the result type of adding int | text to int?

The answer: it depends on which member of the union is active at runtime. The compiler cannot know this statically. So the result type is also a union: int | text. The union propagates through operations.

fn unify(&mut self, left: &FlinType, right: &FlinType) -> FlinType {
    match (left, right) {
        (FlinType::Union(members), right) => {
            if members.iter().any(|m| self.types_compatible(m, right)) {
                left.clone() // Union absorbs the operation
            } else {
                self.report_error("incompatible types in operation");
                FlinType::Unknown
            }
        }
        // ... other unification rules ...
    }
}

Type Narrowing: The Safety Mechanism

Union types without type narrowing would be nearly useless. If a variable is int | text, you cannot call .upper on it -- what if it is an int? You cannot add 1 to it -- what if it is a text? Every operation would require an explicit type check.

Type narrowing solves this by tracking the type through control flow:

value: int | text = getData()

if value is int { // Inside this block: value is int (not int | text) result = value + 1 // safe -- compiler knows value is int }

if value is text { // Inside this block: value is text upper = value.upper // safe -- compiler knows value is text } ```

The is operator performs a runtime type check and narrows the type in the truthy branch. The compiler maintains a type environment that tracks these narrowings:

fn check_if_statement(&mut self, condition: &Expr, then_block: &Block, else_block: &Option<Block>) {
    if let Expr::Is { value, type_check, .. } = condition {
        // Narrow the type in the then-block
        let narrowed = self.narrow_type(value, type_check);
        self.push_scope();
        self.bind_narrowed(value, narrowed);
        self.check_block(then_block);
        self.pop_scope();

// In the else-block, exclude the checked type if let Some(else_block) = else_block { let excluded = self.exclude_type(value, type_check); self.push_scope(); self.bind_narrowed(value, excluded); self.check_block(else_block); self.pop_scope(); } } } ```

The else branch is particularly interesting. If you check value is int and it fails, the compiler knows that in the else branch, value is text -- the union minus the checked type. This is type exclusion, and it means both branches of the if are fully type-safe without any additional annotations.

Union Types with Optionals

Union types interact with optional types in a natural way:

maybe: int | text? = 42
maybe = "hello"
maybe = none        // valid -- text? includes none

The ? applies to the last member of the union. So int | text? means "an int, or an optional text." This follows the principle of least surprise -- the ? is visually attached to text, and that is what it modifies.

In the type checker, optionals in unions are handled by unwrapping:

fn check_union_member(&self, member: &FlinType, actual: &FlinType) -> bool {
    match member {
        FlinType::Optional(inner) => {
            actual == &FlinType::None
                || self.types_compatible(inner, actual)
                || self.types_compatible(member, actual)
        }
        _ => self.types_compatible(member, actual),
    }
}

Union Types with Collections

A union can also contain collection types:

items: int | [int] = [1, 2, 3]
items = 42              // also valid

This is useful for APIs that return either a single item or a list. The type narrowing mechanism works the same way:

if items is [int] {
    for item in items {
        print(item)
    }
} else {
    print(items)        // items is int here
}

Function Signatures with Unions

Union types shine in function parameters and return types:

fn format(input: int | number | text) -> text {
    if input is int {
        return text(input)
    }
    if input is number {
        return text(input)
    }
    return input    // already text
}

fn getValue(flag: bool) -> int | text { if flag { return 42 } else { return "hello" } } ```

The return type int | text tells callers that they must handle both possibilities. The compiler enforces this -- you cannot assign the result to an int variable without first narrowing the type.

The Implementation Session

Session 100 implemented union types alongside slicing (array/string slice operations). The union type implementation touched six files across the compiler:

1. AST (src/parser/ast.rs) -- Added Type::Union(Vec) variant 2. Parser (src/parser/parser.rs) -- Extended parse_type() to handle | separator 3. Type checker types (src/typechecker/types.rs) -- Added FlinType::Union with conversion 4. Type checker (src/typechecker/checker.rs) -- Union support in unify() and types_compatible() 5. Formatter (src/fmt/formatter.rs) -- Union type display formatting 6. Tests -- 7 parser tests, 7 end-to-end compilation tests

The test count went from 1,032 to 1,048 -- 16 new tests across both features. Every existing test continued to pass. This is the discipline that makes language development sustainable: no feature is merged unless the entire existing test suite remains green.

Display and Formatting

Even the display implementation carries design decisions. How should Union(vec![Int, Text, Bool]) be printed?

impl fmt::Display for Type {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Type::Union(types) => {
                for (i, t) in types.iter().enumerate() {
                    if i > 0 {
                        write!(f, " | ")?;
                    }
                    write!(f, "{}", t)?;
                }
                Ok(())
            }
            // ... other variants ...
        }
    }
}

The display format matches the source syntax: int | text | bool. This matters for error messages. When the compiler reports "expected int | text, found bool", the developer sees the same syntax they wrote.

Edge Cases and Design Decisions

Several edge cases required explicit design decisions.

Duplicate members. What about int | int? We normalize unions by deduplicating: int | int becomes int. The compiler silently removes duplicates rather than reporting an error, because duplicates can arise naturally through type alias expansion.

Single-member unions. What about int |? The parser requires at least two members. A single type after | is a syntax error.

Nested unions. What about (int | text) | bool? Unions are flattened: this becomes int | text | bool. There is no nesting in the internal representation.

Union and any. What about int | any? This simplifies to any. If a union contains any, the entire union is any, because any already accepts every type.

fn normalize_union(types: Vec<FlinType>) -> FlinType {
    let mut unique: Vec<FlinType> = Vec::new();
    for t in types {
        if t == FlinType::Any {
            return FlinType::Any;
        }
        if let FlinType::Union(inner) = t {
            for member in inner {
                if !unique.contains(&member) {
                    unique.push(member);
                }
            }
        } else if !unique.contains(&t) {
            unique.push(t);
        }
    }
    if unique.len() == 1 {
        unique.pop().unwrap()
    } else {
        FlinType::Union(unique)
    }
}

Why Union Types Matter for FLIN

Union types are not just a type system feature. They are a philosophy. FLIN is designed for developers who build real applications, and real applications handle heterogeneous data. A configuration value might be a string or a number. A function might return a result or an error. An API response might contain a single object or an array.

Without union types, developers model these situations with any, losing type safety. Or they create wrapper types, adding boilerplate. Union types let developers express what they mean -- "this value is an int or a text" -- and the compiler ensures they handle both possibilities.

Combined with type narrowing, union types make FLIN's type system both expressive and safe. You can describe complex data shapes, and the compiler verifies that every code path handles every possibility. No runtime type errors. No forgotten cases. No undefined is not a function.

What Came Next

Union types opened the door to more advanced type features. Generic types (Session 101) let functions and containers work with any type while preserving type safety. Tagged unions (Session 145) combine union types with associated data for algebraic data types. Pattern matching (Sessions 145-157) provides the ergonomic syntax for working with all of these constructs.

The next article covers generic types -- the feature that completed Phase 2 of FLIN's type system.

---

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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles