Back to flin
flin

Fixing Library Function Resolution

When library functions stopped resolving after hot module reload.

Thales & Claude | March 25, 2026 7 min flin
flinbuglibraryfunction-resolutionhot-reload

A programming language is only as useful as its standard library. FLIN's lib/ directory serves as a project-level standard library -- shared utility functions, validators, and formatters that every page in the application can use. When this library stops working, every page that depends on it breaks.

On January 22, 2026, the modern-notes application refused to compile. The errors were a cascade of type checker failures, all originating from the lib/ directory:

Type error: method 'trim' not found on type ?T0
Type error: method 'length' not found on type ?T0
Type error: function 'time_ago' is not defined

The ?T0 type is FLIN's notation for an unresolved type variable -- the type checker's way of saying "I have no idea what this is." Every function in the library was operating on mystery types, and the type checker could not verify any of their operations.

The Root Cause: Untyped Parameters

FLIN's type checker uses Hindley-Milner type inference, which can often deduce types from usage context. But function parameters in library files have a special challenge: they are defined in one file and called from another. The type checker processes each file independently, so it cannot look at call sites to infer parameter types.

The library files had been written without explicit type annotations:

// lib/utils.flin -- BEFORE
fn capitalize(str) {
    if str.length == 0 { return "" }
    return str.slice(0, 1).uppercase() + str.slice(1, str.length)
}

fn truncate(str, maxLen) { if str.length <= maxLen { return str } return str.slice(0, maxLen) + "..." } ```

Without annotations, the type checker assigned ?T0 (unknown type) to str and ?T1 to maxLen. Since ?T0 has no methods, calls to .length, .trim(), .slice(), and .uppercase() all failed type checking.

The Fix: Explicit Annotations

The solution was systematic: add explicit type annotations to every function parameter in every library file.

// lib/utils.flin -- AFTER
fn capitalize(str: text) {
    if len(str) == 0 { return "" }
    return str.slice(0, 1).uppercase() + str.slice(1, len(str))
}

fn truncate(str: text, maxLen: int) { if len(str) <= maxLen { return str } return str.slice(0, maxLen) + "..." }

fn slugify(str: text) { / ... / } fn isEmpty(str: text) { / ... / } fn isNotEmpty(str: text) { / ... / } fn clamp(value: int, min: int, max: int) { / ... / } fn percentage(value: int, total: int) { / ... / } ```

The same treatment was applied to lib/validators.flin (12 functions) and lib/formatters.flin (all formatting functions).

The Missing Built-in: time_ago

One error was not about type annotations but about a missing built-in function. The formatters library used time_ago() to generate relative time strings like "3 minutes ago" or "yesterday":

fn formatRelative(date: int) {
    return time_ago(date)
}

The time_ago function existed in the VM's native function table but was not registered in the type checker. The VM could execute it, but the type checker rejected it as undefined.

The fix required registration in both places:

// VM registration (src/vm/vm.rs)
register(self, "time_ago", 1, 421);

// VM implementation fn native_time_ago(&mut self) -> VMResult<()> { let ts = self.pop()?.as_int().unwrap_or(0); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0); let relative = crate::vm::builtins::time::time_from_now(ts, now); self.push(Value::Text(relative))?; Ok(()) }

// Type checker registration (src/typechecker/checker.rs) "time_ago" => Some(FlinType::Function { params: vec![FlinType::Int], ret: Box::new(FlinType::Text), min_arity: 1, has_rest: false, }), ```

The Index Syntax Problem

Some library functions used index syntax that the type checker did not support for generic types:

// These caused type checker errors
fn first(arr: list) { return arr[0] }
fn formatNumber(str: text) { return str[0] }

The arr[0] and str[0] syntax requires the type checker to know that list supports indexing and that text supports character access. While both are true at runtime, the type checker's handling of generic list and text types did not include index operator resolution.

Rather than expanding the type checker's capabilities (which would have been a significant change), we adopted two pragmatic approaches:

For strings: Replace index syntax with method calls:

// BEFORE (type checker error)
str[0]               // string indexing
str[1:]              // string slicing

// AFTER (works) str.slice(0, 1) // method call str.slice(1, len(str)) // method call ```

For incompatible functions: Remove them from the library entirely. Functions like first(arr), last(arr), formatNumber(str), and formatCurrency(str) relied on features the type checker could not verify. They were replaced by inline usage of .slice() and built-in functions.

The UTF-8 Boundary Panic

While fixing the library functions, a test in the layout registry panicked with a UTF-8 character boundary error. The test_modern_notes_layout_discovery test was truncating a string at a byte offset that fell in the middle of a multi-byte UTF-8 character.

This is a common trap in Rust: string slicing with byte indices can panic if the index does not fall on a character boundary. The fix was to use character-aware slicing or to adjust the test assertion to avoid truncating multi-byte strings.

Verification

After all fixes, the modern-notes application compiled and ran successfully:

cargo run --bin flin -- dev examples/mini-apps/modern-notes

Shared styles: 3 file(s) from styles/ Translations: 3 language(s) [en, fr, es] Shared lib: 4 file(s) [constants, validators, utils, formatters] Layouts: 1 file(s) [default]

FLIN Dev Server (multi-page) v0.9.2 Local: http://127.0.0.1:3000 Routes: 1 routes discovered ```

All four library files loaded, translations in three languages worked, the layout was applied, and the theme toggle functioned.

Test results improved by 26 from the previous session:

cargo test --lib       -> 3,074 passed (0 failed)
cargo test --test integration_e2e -> 623 passed (0 failed)
Total: 3,697 tests

The Broader Pattern: Type Checker vs. Runtime Gap

This bug illustrates a tension present in every gradually typed language: the type checker and the runtime do not always agree on what is valid.

The VM can execute str[0] on a text value -- it knows how to index into strings at runtime. But the type checker, analyzing the code statically, does not have enough information to verify that str supports indexing when str is typed as text.

There are three ways to resolve this tension:

1. Expand the type checker to understand more operations on each type. This is the ideal solution but requires significant engineering effort for every type-operation pair.

2. Use explicit annotations and method calls that the type checker already understands. This is what we did -- replacing str[0] with str.slice(0, 1), which the type checker can verify.

3. Add escape hatches like any types or unsafe blocks that bypass type checking. We avoided this approach because it defeats the purpose of having a type checker.

For FLIN v1.0, option 2 was the right trade-off. The type checker catches real errors (wrong argument counts, type mismatches, undefined functions) without needing to understand every syntactic variation of every operation. As the language matures, option 1 will gradually expand the set of verified patterns.

The Lesson: Library Code Needs Stricter Types

Application code can often get away with loose typing because the type checker can infer types from the immediate context. Library code cannot. It is called from many contexts, and the type checker processes each file independently.

This creates a rule for FLIN library development: always annotate function parameters in lib/ files. The type checker cannot infer parameter types across file boundaries. Without annotations, every parameter is ?T0, and every operation on it fails type checking.

This rule is not unique to FLIN. TypeScript developers learn the same lesson with .d.ts declaration files. Rust developers learn it with public API boundaries. The principle is universal: at module boundaries, make types explicit. Within modules, let inference do its work.

---

This is Part 164 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [163] The Layout Children Wrapping Bug - [164] Fixing Library Function Resolution (you are here) - [165] The Theme Toggle Bug

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles