16. Operations on built-in values are methods (amends 0014)
- Status: Accepted
- Date: 2026-09-03
Context
ADR 0014 made every operation on
a str / list / map a free function — append(xs, v), keys(m),
len(s) — and said so explicitly for Milestone 1, whose goal was a complete
but small language. It named the follow-up: "Adding methods to built-in types
later is a compatible change… decided in a follow-up ADR." This is that ADR.
The free-function design has three costs that show up as soon as real code is written:
- No chaining.
" a, b ".trim().split(",")has to be writtensplit(trim(" a, b "), ",")— inside-out, and the reader parses it backwards. - A crowded global scope.
append,pop,keys,values,has,get,removeare all top-level names a program cannot use for its own bindings, and none of them says which type it is for without reading the docs. - It fights the language it is part of. Korrin already has
instance.method()for user classes. Havinglistoperations beappend(xs, v)while a user's own collection type usesxs.add(v)is an inconsistency with no upside.
Milestone 2 also adds a real string and collection vocabulary (upper, split,
sort, join, slice, …). Introducing a dozen more free functions would make
all three costs worse.
Decision
Operations on str, list, and map are methods, reached with . like any
instance method. The . operator resolves on these three built-in types as well
as on class instances.
- The M1 collection free functions are removed, not kept as aliases:
append→list.push,pop→list.pop,keys/values/has/get/remove→ the same-namedmapmethods.len(x)→x.len(). There is one way, per the project's core goal; an alias would mean two. print,type,str,int,float,bool,range,inputstay free functions. They construct a value, convert between types, or do I/O — they are not operations on a receiver.type(x)andstr(x)in particular must work uniformly on every value, includingnilandint, which carry no methods.- A method read (
xs.push) evaluates to a callable bound to its receiver, the same shape as a bound instance method. It is normally called immediately. - Mutating
listmethods (push,insert,remove_at,reverse,sort) change the receiver in place and returnnil;popandremove_atreturn the element removed. This matches how a mutable, sharedlistalready behaves and avoids thexs = xs.push(v)misreading 0014 warned about. - Implementation: a
NativeMethodtable per type incrates/korrin/src/interpreter/methods.rs, aValue::BoundNativefor the bound-but-not-yet-called value, and one new arm each in the interpreter's attribute-read and call paths. No lexer, parser, resolver, or grammar change — the syntax is the existingattributeandcallproductions. - The method set for M2 is specified in §8.2–8.4.
crates/korrin/tests/builtins.rsnow checks method coverage against the spec the same way it checks functions: a method missing from §8, or documented but not implemented, fails the build.
Consequences
- Code reads left to right:
text.trim().lower().split(" "). - The global scope drops seven names; the remaining eight builtins are all constructors or I/O, which is a describable rule rather than a list.
- Breaking, on top of removing the free functions: this is the second M2 change (after interpolation) that invalidates M1 code. Every doc example, spec block, golden program, and the guide were swept; the CHANGELOG lists the rename table.
- The interpreter's
.path is no longer instance-only, which is the surface 0014 was deferring. It stays small: three static tables, a linear name lookup, no per-typeValuemachinery beyondBoundNative. lenis nowx.len()everywhere, including inside interpolation ("{items.len()} left"). Slightly more to type thanlen(items); consistent with every other operation, andlenwas the only builtin that read as an operation on its argument.- Future built-in types (a
set, abytes) get methods by adding a table, with no new global names.
Alternatives considered
- Keep 0014 as-is. Rejected: the costs above are real and compound as the standard vocabulary grows. 0014 itself scoped the free-function choice to M1.
- Methods, but keep the free functions as aliases. Two ways to do the same thing, forever — the exact thing Korrin's design goal rules out. A short deprecation window was considered unnecessary for a pre-1.0 language with no external users yet.
- Keep
len(x)as a free function, make everything else a method. Tempting —lenreads well and Python does this. Rejected for consistency:lenis an operation on its argument (unliketypeorstr), so it belongs with the other operations. One rule ("operations are methods, constructors are functions") beats one rule plus an exception. - A hybrid — methods on
list/map, functions forstr.strgains the most from chaining (.trim().lower()); excluding it makes no sense.