Back to flin
flin

Type Guards and Runtime Type Narrowing

How FLIN's is operator enables runtime type checking with compile-time type narrowing -- the bridge between dynamic values and static safety.

Thales & Claude | March 25, 2026 9 min flin
flintype-guardsnarrowingruntime

There is a fundamental tension in any statically typed language that interacts with the real world. The type system knows things at compile time. The real world reveals things at runtime. A JSON response might contain a number or a string. A function might return different types depending on its input. A union-typed variable might be any of its members.

Type guards are the bridge. They are runtime checks that the compiler understands, allowing it to narrow a type from a broad possibility to a specific certainty. In FLIN, the is operator is this bridge.

The is Operator

The is operator checks whether a value has a specific type at runtime:

value: int | text | bool = getData()

if value is int { // Here, value is int -- not int | text | bool result = value + 1 }

if value is text { // Here, value is text upper = value.upper }

if value is bool { // Here, value is bool negated = !value } ```

The is operator does two things simultaneously:

1. Runtime: Checks the actual type of the value and produces a boolean result 2. Compile time: Narrows the type in the truthy branch of the condition

This dual role is what makes type guards powerful. The developer writes one check, and both the runtime and the compiler benefit.

How Narrowing Works

The type checker maintains a type environment -- a mapping from variable names to types. When it enters an if block whose condition is a type guard, it creates a new scope with the narrowed type:

fn check_if_with_type_guard(
    &mut self,
    variable: &str,
    checked_type: &FlinType,
    original_type: &FlinType,
    then_block: &Block,
    else_block: &Option<Block>,
) {
    // Then branch: narrow to the checked type
    self.push_scope();
    self.env.insert(variable.to_string(), checked_type.clone());
    self.check_block(then_block);
    self.pop_scope();

// Else branch: exclude the checked type from the union if let Some(else_block) = else_block { self.push_scope(); let remaining = self.subtract_type(original_type, checked_type); self.env.insert(variable.to_string(), remaining); self.check_block(else_block); self.pop_scope(); } } ```

The push_scope and pop_scope calls ensure that narrowing is confined to the appropriate block. After the if block, the variable reverts to its original type. This is essential for correctness -- the narrowing is only valid within the branch that performed the check.

Type Subtraction

The else branch performs type subtraction: removing the checked type from the union. If a variable is int | text | bool and you check value is int, the else branch knows the value is text | bool:

fn subtract_type(&self, base: &FlinType, removed: &FlinType) -> FlinType {
    match base {
        FlinType::Union(members) => {
            let remaining: Vec<FlinType> = members
                .iter()
                .filter(|m| m != removed)
                .cloned()
                .collect();

match remaining.len() { 0 => FlinType::Never, 1 => remaining.into_iter().next().unwrap(), _ => FlinType::Union(remaining), } } _ => { if base == removed { FlinType::Never } else { base.clone() } } } } ```

If all types are removed, the result is Never -- a type with no values, indicating unreachable code. If one type remains, the union collapses to that single type. Otherwise, the remaining types form a smaller union.

Chained Type Guards

Type guards chain through if-else if-else blocks, progressively narrowing the type:

value: int | text | bool | number = getData()

if value is int { // value: int print("Integer: " + text(value)) } else if value is text { // value: text (was text | bool | number, narrowed by is text) print("Text: " + value) } else if value is bool { // value: bool (was bool | number, narrowed by is bool) print("Boolean: " + text(value)) } else { // value: number (the only remaining possibility) print("Number: " + text(value)) } ```

Each else if sees a progressively narrower type. The final else branch has the type that remains after all checks. The compiler tracks this automatically -- the developer never needs to think about what types remain.

Entity Type Guards

The is operator works with entity types:

entity Dog { name: text, breed: text }
entity Cat { name: text, indoor: bool }

animal: Dog | Cat = getAnimal()

if animal is Dog { print(animal.breed) // safe -- Dog has breed }

if animal is Cat { print(animal.indoor) // safe -- Cat has indoor } ```

Inside the is Dog block, animal.breed is valid because the compiler knows animal is a Dog. Outside the block, animal.breed would be an error because Cat does not have a breed field.

Optional Narrowing

The is operator interacts with optional types. Checking if an optional value is present narrows it from T? to T:

user: User? = User.find(id)

if user is User { // user: User (not User?) print(user.name) } ```

But FLIN also supports a shorter form for optional narrowing -- the truthiness check:

if user {
    // user: User (narrowed from User?)
    print(user.name)
}

Both forms produce the same narrowing. The truthiness check is preferred for optionals because it is shorter and idiomatic. The is operator is preferred for union types because it specifies which member to narrow to.

Narrowing in Match Expressions

Type guards integrate with match expressions through type patterns:

value: int | text | bool = getData()

result = match value { is int -> value + 1 is text -> value.upper is bool -> !value } ```

Each arm narrows the type of value for its body. The is int pattern is a type guard that both checks the type at runtime and narrows it at compile time.

The type checker validates each arm with the narrowed type:

fn check_match_type_arm(
    &mut self,
    subject: &str,
    subject_type: &FlinType,
    checked_type: &FlinType,
    body: &Expr,
) -> FlinType {
    self.push_scope();
    self.env.insert(subject.to_string(), checked_type.clone());
    let result = self.infer_type(body);
    self.pop_scope();
    result
}

Code Generation for Type Guards

At runtime, the is operator compiles to a type tag check:

fn emit_is_check(&mut self, value: &Expr, checked_type: &Type) {
    self.emit_expr(value);
    match checked_type {
        Type::Int => self.emit_op(OpCode::IsInt),
        Type::Number => self.emit_op(OpCode::IsNumber),
        Type::Text => self.emit_op(OpCode::IsText),
        Type::Bool => self.emit_op(OpCode::IsBool),
        Type::Named(name) => {
            self.emit_const(Value::Text(name.clone()));
            self.emit_op(OpCode::IsEntity);
        }
        _ => {
            self.emit_op(OpCode::IsType);
        }
    }
}

Each Is* opcode pops the value from the stack, checks its runtime type tag, and pushes a boolean result. The VM implementation is straightforward:

fn execute_is_int(&mut self) -> Result<(), VmError> {
    let value = self.stack.pop()?;
    let result = matches!(value, Value::Int(_));
    self.stack.push(Value::Bool(result));
    Ok(())
}

fn execute_is_entity(&mut self) -> Result<(), VmError> { let entity_name = self.stack.pop()?; let value = self.stack.pop()?; let result = match (&value, &entity_name) { (Value::Object(obj), Value::Text(name)) => obj.entity_name == *name, _ => false, }; self.stack.push(Value::Bool(result)); Ok(()) } ```

Negated Type Guards

Type guards can be negated with !:

value: int | text = getData()

if !(value is int) { // value: text (the only remaining possibility) print(value.upper) } ```

The compiler understands negation and performs the appropriate type subtraction. !(value is int) in a union of int | text narrows to text -- the same result as the else branch of if value is int.

Combining Guards with Logical Operators

Type guards combine with && and ||:

value: int | text = getData()

if value is int && value > 0 { // value: int (narrowed by is int) // The > 0 check is valid because value is known to be int print("Positive integer") } ```

The && operator applies narrowing from left to right. The left operand value is int narrows the type, and the right operand value > 0 is checked with the narrowed type. If the left is false, the right is never evaluated (short-circuit), so the narrowing never takes effect.

The || operator does not narrow because either side might be true:

if value is int || value is text {
    // value is still int | text -- no narrowing
    // (both branches of || could be the one that succeeded)
}

Practical Patterns

Safe Entity Access

fn get_display_name(entity: User | Organization) -> text {
    if entity is User {
        return entity.first_name + " " + entity.last_name
    }
    // entity is Organization here
    return entity.company_name
}

API Response Handling

response: Success | NotFound | ServerError = fetch("/api/data")

if response is Success { render(response.data) } else if response is NotFound { show_404() } else { // response: ServerError log(response.message) show_error_page() } ```

Collection Processing

items: [int | text] = [1, "hello", 2, "world", 3]

numbers = items.where(x => x is int) // [int] strings = items.where(x => x is text) // [text] ```

The where method with a type guard produces a narrowed list type. The compiler infers that filtering by is int produces [int], not [int | text].

The Relationship Between is, match, and if

FLIN offers three ways to work with type guards, each suited to different situations:

if value is Type -- for simple one-type checks:

if user is Admin {
    show_admin_panel()
}

match value { is Type -> ... } -- for exhaustive multi-type handling:

match value {
    is int -> handle_int(value)
    is text -> handle_text(value)
    is bool -> handle_bool(value)
}

Truthiness check -- for optional narrowing:

if user {
    print(user.name)
}

All three narrow types. All three are understood by the compiler. The developer chooses based on the situation: simple check, exhaustive dispatch, or optional unwrap.

Why Runtime Narrowing Matters

Type guards solve a real problem. Static type systems are conservative -- they track what a value could be, not what it is. A variable of type int | text could be either type at any point. Without type guards, the developer cannot safely perform type-specific operations.

The alternative is explicit casting, which is both verbose and unsafe:

// Bad: explicit cast (no compile-time safety)
value = getData() as int    // crashes if value is actually text

// Good: type guard (compile-time and runtime safety) if value is int { // compiler verifies all operations are valid for int } ```

Type guards provide the safety of explicit checks with the ergonomics of implicit narrowing. The developer writes one is check, and the compiler propagates the type information through the entire block.

This is the core promise of FLIN's type system: safety you always get. Not safety you have to fight for. Not safety that requires verbose annotations. Safety that flows naturally from the way you write code.

---

This is Part 40 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: - [38] The Pipeline Operator: Functional Composition in FLIN - [39] Tuples, Enums, and Structs - [40] Type Guards and Runtime Type Narrowing (you are here) - [41] The Never Type and Exhaustiveness Checking - [42] Generic Bounds and Where Clauses

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles