Back to flin
flin

#164 -- Fixing Library Function Resolution

When library functions stopped resolving after hot module reload.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 7 min flin
EN/ FR/ ES
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:

flin// 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.

flin// 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":

flinfn 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:

rust// 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:

flin// 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:

flin// 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:

bashcargo 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.
  1. 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.
  1. 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

Thales & Claude deblo

Step Zero Wasn’t Enough: How Validating A Constructor But Not The Runtime Took Down Every Déblo Voice Session The Hour We Shipped Real-Time Camera Streaming

Phase 14 shipped Déblo Eyes — real-time camera streaming over LiveKit to Gemini Live native audio. The first deploy took down every voice session in production within ninety seconds because our Step 0 had validated the constructor without exercising the runtime path. The build log of how Déblo got eyes, what an incomplete pre-flight check cost us, and which polish items we shipped versus deferred.

30 min May 20, 2026
debloclaude-opus-4.7claude-codegemini-live +25
Thales & Claude deblo

The Em-Dash That Killed Production: How One Marketing Tagline In An HTTP Header Took Down Déblo’s Chat For 24 Hours

Two days before App Store submission, Déblo’s entire chat product silently broke. No spinner, no toast, no error in the UI — just dead air. The 24-hour outage came down to a single « é » in an HTTP header value raising UnicodeEncodeError before any request to OpenRouter ever left the backend. The post-mortem of a false hypothesis, a Sentry trace, and a 6-line fix that unblocked the launch.

27 min May 19, 2026
debloclaude-opus-4.7claude-codeincident +19
Thales & Claude deblo

Six Hours From Empty Page to Apple Review — How We Submitted Déblo to the App Store, Live

Live walkthrough of submitting Déblo to the iOS App Store in six hours: what Apple’s validators rejected (a Unicode superscript), what we corrected (a Promotional Text wasted on third-party brands), and the iOS ASO mechanics almost everyone gets wrong.

27 min May 13, 2026
debloclaude-opus-4.7claude-codeapp-store +16