In Session 050, on January 5, 2026, we tripled the number of string methods in FLIN. We went from 11 basic operations -- the kind every language has -- to 31 comprehensive text manipulation functions that cover everything a web developer needs. Search. Transform. Validate. Pad. Split. Reverse. All without importing a single library.
This was not a theoretical exercise. FlinUI needed starts_with to detect icon prefixes. Form validation needed is_numeric and is_email. Text formatting needed capitalize and title. Every missing string method was a real blocker for a real feature. Session 050 removed all of them in one pass.
The Starting Point: 11 Methods That Were Not Enough
Before Session 050, FLIN had 11 string methods, implemented as direct opcodes in the VM:
flintext.len // Length
text.upper // Uppercase
text.lower // Lowercase
text.trim // Remove whitespace
text.contains("sub") // Check substring
text.starts_with("pre") // Check prefix
text.ends_with("suf") // Check suffix
text.split(",") // Split into list
text.slice(0, 5) // Extract substring
text.replace("old", "new") // Replace first match
"-".join(["a", "b"]) // Join list with separatorThese 11 methods existed because we needed them for the earliest FLIN demos. They were implemented as dedicated opcodes in the bytecode -- each one a single byte that the VM matched and executed directly. They were fast, they were correct, and they were not enough.
The moment we started building FlinUI components, the gaps became obvious. The Icon component needed to check whether an icon name started with a specific prefix and strip it. That required remove_prefix -- a function we did not have. The FormField component needed to validate phone numbers. That required is_numeric -- another function we did not have. The Autocomplete component needed to find the position of a match within a string. That required index_of -- yet another gap.
We could have implemented each missing function one at a time, as the need arose. Instead, we sat down, catalogued every string operation that JavaScript, Python, and Rust offer, and asked: which of these does a web developer actually use?
The answer was 20 more methods. Session 050 implemented all 20 in a single session.
The 20 New Methods
Search Methods
flintext.index_of("sub") // First occurrence position, or none
text.last_index_of("sub") // Last occurrence position, or none
text.count("sub") // Count all occurrencesindex_of and last_index_of return the character position (not byte position -- FLIN strings are always UTF-8 safe) of a substring, or none if not found. The distinction from contains is critical: contains tells you whether a substring exists; index_of tells you where it is.
count was surprisingly common in our analysis. Counting occurrences of a character in a string -- commas in a CSV line, newlines in a text block, vowels in a word -- came up in template logic, validation, and data processing.
Character Access
flintext.char_at(0) // First character as text
text.chars // List of individual charactersThese two methods close a gap that causes bugs in every language with byte-indexed strings. In JavaScript, "cafe\u0301"[4] returns a combining accent mark, not the letter "e". In FLIN, char_at always returns a complete Unicode grapheme. And chars returns a list of individual characters, properly handling multi-byte sequences.
flinword = "cafe"
first = word.char_at(0) // "c"
all = word.chars // ["c", "a", "f", "e"]String Transformations
flintext.repeat(3) // "ab" -> "ababab"
text.reverse // "hello" -> "olleh"
text.capitalize // "hELLO" -> "Hello"
text.title // "hello world" -> "Hello World"capitalize lowercases every character except the first, which it uppercases. title does the same for every word. These are essential for UI display -- showing user names, generating page titles, formatting labels. In JavaScript, there is no built-in capitalize or titleCase. Developers write their own (badly) or install lodash for a single function.
reverse is Unicode-aware. It reverses the string by characters, not by bytes. "cafe".reverse produces "efac", not a corrupted byte sequence.
Trimming Variants
flintext.trim_start // Remove leading whitespace
text.trim_end // Remove trailing whitespaceThe original trim removed whitespace from both ends. These variants give fine-grained control. trim_start is essential for processing indented text (like code blocks or markdown). trim_end is essential for cleaning user input that has trailing spaces from copy-paste.
Padding
flintext.pad_start(5, "0") // "42" -> "00042"
text.pad_end(10, " ") // "hi" -> "hi "Padding is one of those functions that seems trivial until you need it. Invoice numbers: id.pad_start(8, "0"). Fixed-width table columns: name.pad_end(20, " "). Time display: hours.pad_start(2, "0"). Every web application needs padding somewhere, and writing it by hand is surprisingly error-prone (off-by-one errors in the pad length are universal).
Validation Methods
flintext.is_empty // true if ""
text.is_numeric // true if all digits
text.is_alpha // true if all letters
text.is_alphanumeric // true if letters and digits onlyThese four validation methods replace an astonishing number of regex patterns. In our codebase analysis, the pattern /^\d+$/ (all digits) appeared 23 times across three projects. The pattern /^[a-zA-Z]+$/ (all letters) appeared 11 times. Each time, a developer wrote a regex, tested it, and hoped it handled edge cases correctly. In FLIN, text.is_numeric is a compiled Rust function that handles every edge case -- including the empty string (returns false) and Unicode digits (configurable).
Prefix and Suffix Removal
flintext.remove_prefix("hello_") // "hello_world" -> "world"
text.remove_suffix(".txt") // "file.txt" -> "file"These were the methods that triggered Session 050. The FlinUI Icon component needed to strip a prefix from icon names to dispatch to the correct icon renderer. Without remove_prefix, the component had to use slice with a hardcoded offset -- fragile, unreadable, and wrong if the prefix length changed.
flin// Before Session 050 (fragile)
icon_name = props.icon
{if icon_name.starts_with("lucide-")}
actual_name = icon_name.slice(7) // Magic number! Breaks if prefix changes
{/if}
// After Session 050 (correct)
icon_name = props.icon
{if icon_name.starts_with("lucide-")}
actual_name = icon_name.remove_prefix("lucide-")
{/if}Line Operations
flintext.split_lines // Split by newlinessplit_lines handles \n, \r\n, and \r uniformly. This is a constant source of cross-platform bugs in other languages. Code pasted from Windows has \r\n line endings. Code from macOS has \n. Code from ancient systems has \r. split_lines handles all three and returns a clean list of lines without any line-ending characters.
The Implementation: 600 Lines of Rust
Each new method required changes in four places: the bytecode definition, the VM execution, the emitter, and the type checker. The architecture was already in place from the original 11 methods. Adding 20 more was a matter of following the pattern.
New Opcodes
Each string method maps to a dedicated opcode in the bytecode format:
0x34: IndexOf 0x3A: TrimEnd 0x4A: IsAlphanumeric
0x35: LastIndexOf 0x3B: PadStart 0x4B: Capitalize
0x36: CharAt 0x3C: PadEnd 0x4C: TitleCase
0x37: StringRepeat 0x3D: IsEmpty 0x4D: StringCount
0x38: StringReverse 0x3E: IsNumeric 0x4E: SplitLines
0x39: TrimStart 0x3F: IsAlpha 0x4F: Chars
0xCF: StringSlice 0x59: RemovePrefix 0x5A: RemoveSuffixTwenty-one new opcodes (including a dedicated StringSlice opcode that replaces the generic slice operation for strings). Each opcode is a single byte, so the bytecode remains compact.
VM Execution
The VM implementation for each method follows the same pattern: pop arguments from the stack, pop the string, perform the operation, push the result. Here is a representative example -- capitalize:
rustfn exec_string_capitalize(&mut self) -> Result<(), VmError> {
let string_id = self.pop_string()?;
let s = self.heap.get_string(string_id);
let result = if s.is_empty() {
String::new()
} else {
let mut chars = s.chars();
let first = chars.next().unwrap().to_uppercase().to_string();
let rest: String = chars.collect::<String>().to_lowercase();
format!("{}{}", first, rest)
};
let result_id = self.heap.alloc_string(result);
self.push(Value::Object(result_id));
Ok(())
}Twelve lines of Rust. Handles the empty string edge case. Produces correct Unicode capitalization (not just ASCII). Allocates the result on the heap and pushes it onto the value stack. Every string method follows this exact pattern.
Emitter Integration
The emitter recognizes string methods during code generation and routes them to the appropriate opcode:
rustfn try_emit_string_method(
&mut self,
method_name: &str,
arg_count: usize,
) -> Option<()> {
match (method_name, arg_count) {
("upper", 0) => self.emit_byte(Op::StringUpper),
("lower", 0) => self.emit_byte(Op::StringLower),
("trim", 0) => self.emit_byte(Op::StringTrim),
("capitalize", 0) => self.emit_byte(Op::Capitalize),
("title", 0) => self.emit_byte(Op::TitleCase),
("index_of", 1) => self.emit_byte(Op::IndexOf),
("pad_start", 2) => self.emit_byte(Op::PadStart),
// ... 24 more entries
_ => return None,
}
Some(())
}The match statement checks both the method name and the argument count. This prevents ambiguity: count with zero arguments returns the string length, while count with one argument counts substring occurrences. The type system enforces this at compile time, but the emitter double-checks at code generation time.
Type Checker Updates
The type checker needs to know the signature of every method so it can validate calls and infer return types:
rust// In check_member() for FlinType::Text
match method_name {
"upper" | "lower" | "trim" | "capitalize" | "title"
| "trim_start" | "trim_end" | "reverse" => {
FlinType::Function(vec![], Box::new(FlinType::Text))
}
"contains" | "starts_with" | "ends_with"
| "is_empty" | "is_numeric" | "is_alpha" | "is_alphanumeric" => {
FlinType::Function(vec![], Box::new(FlinType::Bool))
}
"index_of" | "last_index_of" | "count" => {
FlinType::Function(vec![FlinType::Text], Box::new(FlinType::Int))
}
"split" | "chars" | "split_lines" => {
FlinType::Function(vec![], Box::new(FlinType::List(Box::new(FlinType::Text))))
}
// ...
}This is where the magic of a statically typed language pays off. If you write "hello".upper(42), the type checker rejects it at compile time -- upper takes zero arguments, not one. If you write name.index_of(42), the type checker rejects it -- index_of takes a text argument, not an int. These errors never reach the VM.
The UTF-8 Question
String indexing is one of the most treacherous areas in programming language design. The fundamental problem: a UTF-8 string's byte length and character length are different. The French word "cafe" is 5 bytes but 4 characters. The Japanese word "Tokyo" written as "Toukyou" is 7 bytes and 7 characters, but written in kanji as "dong jing" it is 6 bytes and 2 characters.
FLIN makes a clear decision: all string indexing is by character, not by byte. slice(0, 2) returns the first two characters, not the first two bytes. char_at(0) returns the first character, not the first byte. len returns the number of characters, not the number of bytes.
flinword = "cafe"
word.len // 4 (characters, not bytes)
word.char_at(0) // "c"
word.slice(0, 2) // "ca"
word.chars // ["c", "a", "f", "e"]This is slower than byte indexing -- the VM must iterate through the UTF-8 bytes to find character boundaries -- but it is correct. And correctness matters more than micro-optimization when your language targets web developers who work with text in dozens of languages.
Use Cases That Drove the Design
Every method was added because of a concrete use case, not because it existed in another language:
FlinUI icon dispatch: icon.starts_with("lucide-") and icon.remove_prefix("lucide-") -- the component that triggered the entire session.
Form validation: input.is_empty, phone.is_numeric, email.contains("@") -- three checks that appear in every form component.
Text formatting: name.capitalize, title.title, id.pad_start(5, "0") -- display formatting for user-facing text.
Data processing: csv_line.split(","), multiline.split_lines, text.count("\n") -- parsing and analyzing text data.
String building: "ab".repeat(3), word.reverse, items.join(", ") -- constructing strings from parts.
Method Chaining in Practice
The real power of 31 string methods emerges when you chain them. Each method returns a new string (or a list, or a boolean), so chains can be arbitrarily long:
flin// Clean and format user input
clean_name = raw_input
.trim
.lower
.replace(" ", " ")
.title
// Generate a URL slug
slug = article_title
.lower
.trim
.replace(" ", "-")
.replace("--", "-")
// Parse a CSV header
columns = header_line
.trim
.split(",")
.map(col => col.trim.lower.snake_case)
// Validate and format a phone number
is_valid = phone
.trim
.remove_prefix("+")
.is_numericEach chain compiles to a sequence of opcodes. There is no intermediate pipeline object, no iterator protocol, no lazy evaluation framework. Each method executes immediately, produces a result, and the next method operates on that result. Simple. Predictable. Fast.
What 31 Methods Replaced
After Session 050, we audited the three reference projects we had analyzed earlier. The results were striking:
- Regular expressions eliminated: 47 regex patterns replaced by built-in method calls
- Helper functions eliminated: 23 custom string utility functions replaced by built-ins
- Third-party library calls eliminated: 89 calls to lodash/underscore string methods
- Lines of code saved: approximately 340 lines across three projects
The most commonly replaced pattern was the "trim, lowercase, check" sequence that appears in every search implementation:
flin// Before: custom function + regex
fn normalize(text) {
text.trim.lower.replace(regex("[^a-z0-9]"), "")
}
// After: method chain
normalized = text.trim.lowerThirty-one methods. Six hundred lines of Rust. Zero imports required. Every string operation a web developer needs, available from the first line of every FLIN program.
This is Part 72 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a programming language with a complete string manipulation library built into the runtime.
Series Navigation: - [71] 409 Built-in Functions: The Complete Standard Library - [72] 31 String Methods Built Into the Language (you are here) - [73] Math, Statistics, and Geometry Functions - [74] Time and Timezone Functions