Kotlin → WebAssembly · a compiler written in C
A Kotlin compiler
that runs in a browser tab.
minikotlin is written from scratch in C and emits WebAssembly GC bytecode by hand — no JVM, no LLVM, no Binaryen, no Gradle. The compiler is itself compiled to WASM, so .kt source goes in and a running .wasm module comes out, entirely in the tab.
- backend
- WASM-GCstructs · call_ref · EH
- server
- noneruns client-side
- end-to-end tests
- 366frontend: 657
- runtime deps
- 0nothing installed
greeter — minikotlin Studio
// Main.kt + Greeter.kt compile as one unit fun main() { val g = Greeter("WebAssembly") println(g.greet()) (1..3).forEach { println("tick $it") } }
build 2 .kt → main.wasm · ok, 41ms
Hello, WebAssembly
tick 1
tick 2
tick 3
the pipeline .kt→ lex→ parse→ sema→ HIR→ MIR→ WASM-GC→ run
01
One pass, all the way down to bytecode.
No intermediate VM, no external backend. The frontend — lexer, parser, semantic analysis (it’s called mkf) — hands off to two of its own IRs before writing WASM-GC by hand.
input
Kotlin source
Multiple .kt files, compiled as one unit so they can see each other.
frontend · mkf
lex · parse · sema
Names, types and smart-casts resolved. 657 frontend tests.
high IR
HIR
A desugared, typed tree that still sits close to the language.
mid IR
MIR
Lowered to ops, locals, struct layouts and vtables.
codegen
WASM-GC
Bytecode emitted directly. No LLVM, no Binaryen in the loop.
output
main.wasm
Instantiated and run in the same browser tab.
The compiler ships as WASM itself, so it runs where your code runs — no toolchain to install.
02
The Kotlin it speaks today.
Not a token subset. These are lowered properly onto the WASM-GC type system — each one has end-to-end tests behind it.
- Classes & objectsobject model
- Inheritance (
open/override), interfaces with default methods,data classwith generatedequals/hashCode/copy,enum, and named, companion & anonymousobjectexpressions. - Sealed & smart-castscontrol flow
sealedhierarchies with exhaustivewhen,ischecks compiled toref.test, and flow-sensitive smart-casting that holds across branches.- Null safetytypes
- Nullable types end to end —
?.safe calls,?:elvis and!!assertions — including nullable primitives, boxed throughAny. - Genericstypes
- Type parameters on functions and classes —
fun <T> id(x: T): T— lowered over a boxedAnyrepresentation. - Operators & extensionsergonomics
- Operator overloading (
plus,get, …) dispatched to the LHS class, extension functions in their own namespace, and custom accessors with a backingfield. - Coroutinesnon-blocking
launch,delayandcoroutineScope— real suspension compiled as CPS over closures, with no Asyncify, no JSPI and no threads.- Standard libraryhand-written
String/Charoperations, list higher-order functions (map/filter/forEach…),kotlin.math, and the scope functionslet/apply/run/also/with.
03
How a Kotlin idea becomes a WASM instruction.
The lowering is the interesting part of any compiler. Four real ones — each maps a language construct onto a concrete WASM-GC mechanism, written by hand.
L.01
class instance → struct.new
Every class becomes a GC struct type; properties are real struct fields. Allocation is struct.new, not a hand-rolled heap of bytes.
L.02
virtual call → call_ref
Open and overridden methods go through a per-class vtable. A virtual call is a function-reference load followed by call_ref — true dynamic dispatch.
L.03
type check → ref.test
An is check and a when (x) { is T -> } arm compile to ref.test, and the narrowed value is reused through a ref.cast — smart-casting for free.
L.04
coroutine → CPS closure
A suspension point splits the function at the seam and captures the rest as a continuation. A bare delay hands a token to the host and resumes from setTimeout — genuinely off the stack.
04
A specimen, compiled and run.
Everything below is supported Kotlin. The Studio highlights it with the compiler’s own lexer, then runs the resulting WASM in place.
import kotlinx.coroutines.* sealed class Lane(val id: Int) class Fast : Lane(1) class Slow : Lane(2) fun Lane.pace(): Long = when (this) { is Fast -> 120 is Slow -> 300 } fun main() = runBlocking { val lanes = listOf(Fast(), Slow()) coroutineScope { lanes.forEach { lane -> launch { delay(lane.pace()) println("lane ${lane.id} in") } } } println("race over") }
Two coroutines, actually racing.
Each launch suspends at its delay and yields. The faster lane resumes first; coroutineScope waits for both children before the last line runs. No blocking and no Asyncify — the suspension is compiled into continuation closures.
The sealed Lane, the when (this) { is … } dispatch and the Lane.pace() extension are all lowered for real, not interpreted.
> lane 1 in
> lane 2 in
> race over