MEP-50 research note 03, Prior art and source-to-Kotlin transpilers
Author: research pass for MEP-50 (Mochi to Kotlin transpiler). Date: 2026-05-23 (GMT+7). Sources: JetBrains documentation for J2K, dukat, KSP, FIR, kotlinx-ast, KotlinPoet; the public design notes of the Compose Compiler plugin (Jetpack Compose architecture talk, Google I/O 2024); the J2CL repository on github.com/google/j2cl; the TeaVM repository on github.com/konsoletyper/teavm; the Scala.js and Scala Native design notes; the Mochi sibling research bundles (MEP-45, MEP-46, MEP-47, MEP-48, MEP-49).
This note surveys the prior art that a Mochi-to-Kotlin transpiler must either build on, learn from, or deliberately diverge from. The survey is grouped by the kind of system: (1) source-to-Kotlin transpilers that already exist (J2K, dukat), (2) Kotlin compiler architecture pieces the transpiler will reuse (FIR, KSP, KotlinPoet, kotlinx-ast), (3) cross-platform transpiler analogues that informed our design choices (J2CL, TeaVM, Scala.js, Scala Native, Kotlin/Wasm), (4) the Compose Compiler plugin as a reference for IR-level transformations, (5) the Mochi sibling backends (MEP-45 to MEP-49). Each entry names the project, summarises what it does, identifies the specific lesson MEP-50 takes, and either inherits or rejects the pattern with the reason.
The single biggest takeaway from this survey: nobody has shipped a production source-to-Kotlin transpiler from a non-Java source language, other than dukat (TypeScript to Kotlin definitions) and the internal IntelliJ J2K (Java to Kotlin) converter. MEP-50 occupies genuinely under-explored territory. The closest parallels are the JVM-targeting transpilers from non-Java languages (Scala, Clojure, Groovy, Frege) which all chose JVM bytecode directly rather than emitting .scala/.clj/.groovy/.fr source. MEP-50's choice to emit Kotlin source (rather than JVM bytecode, which MEP-47 already does) is therefore a deliberate divergence with a clear payoff: one codegen feeds JVM + Native + JS + Wasm. See 02-design-philosophy §4.
1. J2K, JetBrains Java-to-Kotlin converter
What it is. J2K is the IntelliJ IDEA / Android Studio bundled converter that ingests Java source and emits Kotlin source. Initial release 2011 (with the original Kotlin announcement); current version is K2-backed (since IntelliJ 2024.1, the K2 J2K release). Used by millions of developers as the canonical migration path from Java codebases to Kotlin.
How it works. J2K consumes Java PSI (the IntelliJ Program Structure
Interface, IDE-resident parsed AST) and emits Kotlin PSI. The
transformation is PSI-to-PSI: never IR, never bytecode. The output is
formatted Kotlin source that compiles. J2K does not preserve
bit-for-bit semantics: it warns the user when the Kotlin equivalent
differs (e.g., Java null semantics vs Kotlin nullable types, Java
switch exhaustiveness vs Kotlin when).
Lesson for MEP-50. PSI-to-PSI is not the right shape for us:
Mochi has its own typed AST and the aotir IR (reused from MEP-45);
we do not parse Kotlin to do the transform. But J2K's output style
(idiomatic Kotlin with val, data class, when, expression bodies)
is the gold standard for what Mochi-generated Kotlin should look like.
A user reading Mochi-emitted Kotlin should see code indistinguishable
from J2K output on equivalent Java input.
What we take. The output formatting conventions (4-space indent,
expression-body functions where natural, val for immutable, data class
for records). The pragma // converted by J2K is mirrored by our
// generated by mochi build header. The principle that the transpiler
should produce code a Kotlin developer would write by hand.
What we reject. The PSI-to-PSI architecture; we have an IR.
2. dukat, JetBrains TypeScript-to-Kotlin definitions converter
What it is. dukat (github.com/Kotlin/dukat) is the JetBrains
official converter from TypeScript .d.ts (type definition) files into
Kotlin external declarations for Kotlin/JS. It enables Kotlin/JS code
to consume npm packages with TypeScript types. Status: maintained but
moving slowly; the long-term replacement is karakum (a newer Kotlin
DSL for JS interop).
How it works. dukat parses TypeScript using a JS-side parser
(actually the TypeScript compiler itself, via Node), produces a
language-agnostic AST in JSON, then a Kotlin-side process emits Kotlin
external declarations.
Lesson for MEP-50. The two-phase split (parse on language A,
emit on language B) is what we already do, with Mochi parser feeding
aotir IR feeding Kotlin emit. dukat's challenges (TypeScript's
structural-type-with-overloads vs Kotlin's nominal type system, optional
chaining, intersection types) do not arise for Mochi because Mochi's
type system is the simpler one. We borrow the split pattern but not
the type-translation specifics.
What we take. The external declaration shape for Kotlin/JS FFI:
external interface OpenAIClient {
fun chat(req: ChatRequest): Promise<ChatResponse>
}
is the canonical Kotlin/JS pattern that we emit for Mochi extern fun
declarations targeting Kotlin/JS.
What we reject. The TypeScript-specific type-system translations (union types, intersection types, conditional types). Mochi has none of these.
3. KotlinPoet, the JetBrains Kotlin source emitter
What it is. KotlinPoet (github.com/square/kotlinpoet) is a Java library for building Kotlin source code programmatically. Maintained by Square / Block (the makers of OkHttp, Retrofit, Moshi). 2018-present. The de-facto standard tool for code generation in the Kotlin ecosystem.
How it works. Provides FileSpec, TypeSpec, FunSpec,
PropertySpec, ParameterSpec, CodeBlock builders. Output is well-
formatted Kotlin source. Handles import collation automatically; emits
KDoc comments; supports KMP expect/actual declarations.
Lesson for MEP-50. This is the right tool for our emit phase. The
codegen pass in transpiler3/kotlin/lower/ consumes aotir IR and
produces KotlinPoet FileSpec values that serialize to .kt source. We
do not write raw strings; KotlinPoet's import collation alone justifies
the dependency.
What we take. KotlinPoet as the source-emit IR. See 05-codegen-design §1 for the IR pipeline and §18 for KotlinPoet usage samples.
What we reject. The pre-2.0 KotlinPoet API (it lacked sealed interface support until 1.16); we pin to KotlinPoet 1.18+.
4. Kotlin FIR (Frontend IR), the K2 compiler frontend
What it is. FIR is the Kotlin 2.0 compiler's frontend
intermediate representation, replacing the legacy PSI-based frontend
(K1). FIR is a tree-based IR with explicit phases for resolution,
inference, and analysis. Public API status: still evolving (some
internals marked @FirImplementationDetail); compiler plugin authors
are encouraged to use it.
Lesson for MEP-50. We do not go through FIR. Our IR is
aotir, the Mochi-side IR. FIR is what Kotlin compiler plugins
(Compose Compiler, KSP-2.0) operate on; we are not a Kotlin compiler
plugin, we are a Mochi compiler producing Kotlin source for the Kotlin
compiler to ingest. The Mochi-to-Kotlin transformation lives entirely
above the Kotlin compiler.
What we take. The architectural inspiration: a tree-based IR with
explicit phases (resolution, inference, lowering). MEP-45's aotir
already follows this pattern.
What we reject. Direct use of FIR APIs. The instability of the internal types would couple Mochi releases to Kotlin compiler releases in a brittle way.
5. KSP, Kotlin Symbol Processing
What it is. KSP (Kotlin Symbol Processing, github.com/google/ksp) is Google's annotation-processing replacement for the legacy kapt. KSP-2.0 (released 2024-Q3) uses the FIR frontend directly. Used by Room, Hilt, Dagger, Moshi, Compose Compiler.
Lesson for MEP-50. KSP processors consume Kotlin source and
emit Kotlin source as additional files. This is interesting because
Mochi-emitted Kotlin code might be the input to a KSP processor
(e.g., a Mochi @Serializable data class consumed by kotlinx.serialization's
KSP processor at downstream compile time). MEP-50 must ensure
Mochi-emitted Kotlin code is KSP-friendly: clean imports, no internal
hacks, proper annotation propagation.
What we take. Emit clean public Kotlin that KSP can consume.
Specifically: serialization annotations (@Serializable) propagate
from Mochi derive Json declarations; @JvmStatic, @JvmField
propagate from Mochi JVM-FFI hints.
What we reject. Authoring our own KSP processor. We do not need one (we are above the Kotlin compiler, not inside it).
6. kotlinx-ast, the third-party Kotlin parser
What it is. kotlinx-ast (github.com/kotlinx-ast/kotlinx.ast, not an official JetBrains project) provides an ANTLR-based Kotlin parser. Useful for tooling that wants to read Kotlin source without pulling in the full Kotlin compiler.
Lesson for MEP-50. Not directly applicable; we emit Kotlin, we do not parse it. The one place we might use a Kotlin parser is in the FFI-bridge generator (read user's external Kotlin module to discover types), but we prefer to use KSP for that.
What we take. Nothing directly.
7. J2CL, Google's Java-to-Closure-JS transpiler
What it is. J2CL (github.com/google/j2cl) is Google's Java to JavaScript transpiler, the successor to GWT. Used internally for Google's web products (Sheets, Slides) to share business logic across Android (Java) and web (Closure JS). Open-source under Apache-2.0.
How it works. Consumes Java bytecode (via the JDT, Eclipse's Java compiler), emits JavaScript that is then minified by Closure Compiler. The Closure Compiler's dead-code elimination is the critical post-processing step; without it, output sizes would be unusable.
Lesson for MEP-50. Two big lessons. First, emitting source code
of a managed-runtime target language as the IR boundary (here, Closure
JS via Java; for us, Kotlin via Mochi) is a proven viable architecture
for cross-platform code sharing. Second, dead-code elimination at
the target compiler is non-negotiable for binary-size-sensitive
deployments (browser, mobile). Mochi-emitted Kotlin code must be
ProGuard / R8 / Kotlin DCE friendly: public APIs are tagged with
@JvmStatic or @Keep only where the user explicitly opts in; the
rest is fair game for the minifier.
What we take. The architecture pattern (source-as-IR-boundary) and the discipline around dead-code elimination friendliness. ProGuard/R8 rules for the runtime library are documented in 10-build-system §17.
What we reject. J2CL's reliance on Closure Compiler. We do not depend on Closure; Kotlin/JS uses webpack and the built-in Kotlin DCE.
8. TeaVM, Java-to-JS/Wasm transpiler
What it is. TeaVM (github.com/konsoletyper/teavm) is a Java/Kotlin/Scala bytecode to JS/Wasm transpiler. Apache-2.0. Used by several browser-game projects and by some Apache Commons libraries that ship a JS variant.
How it works. Consumes JVM bytecode (so any JVM language can feed it after compilation), performs static analysis to compute a closure of reachable code, emits JS or Wasm.
Lesson for MEP-50. TeaVM proves the feasibility of getting Kotlin code to JS/Wasm without using Kotlin/JS or Kotlin/Wasm. But the Kotlin official targets (Kotlin/JS IR backend, Kotlin/Wasm) have caught up and are now the recommended path. MEP-50 uses the official Kotlin targets, not TeaVM. TeaVM remains interesting as a fallback if Kotlin/Wasm's Alpha status proves blocking.
What we take. Awareness of the fallback. Documented as Alternative A5 in 12-risks-and-alternatives (deferred to v2 if Kotlin/Wasm stalls).
What we reject. TeaVM as the primary path; it adds another compiler in the chain and its output is not as well-integrated with Kotlin tooling.
9. Scala.js and Scala Native
What it is. Scala.js (github.com/scala-js/scala-js) is a Scala-to- JS compiler, in production since 2015 at companies like Lightbend and Wix. Scala Native (github.com/scala-native/scala-native) is a Scala-to- LLVM-IR compiler, less mature (release 0.5.x as of 2024).
How it works. Both share the Scala frontend (scalac), then fork at the backend. Scala.js produces JS source; Scala Native produces LLVM bitcode.
Lesson for MEP-50. This is the closest architectural parallel to MEP-50: a multi-platform delivery for one source language via shared frontend, per-target backend. Lessons:
- A single user-facing language with multiple deploy targets is a product win, not just a compiler-engineering win. Scala.js users reach the browser; Scala Native users reach LLVM. Mochi-to-Kotlin reaches every Kotlin target.
- Backend-specific FFI hooks matter. Scala.js's
@JSExportand Scala Native's@externare the FFI escape hatches. MEP-50's per-target FFI annotations (externalfor JS,@CNamefor Native,external funfor JVM JNI) follow this pattern. - Build-system integration is half the work. Scala.js's sbt plugin and Scala Native's sbt plugin are large. MEP-50's Gradle plugin is the equivalent surface; see 10-build-system.
What we take. The architectural blueprint (frontend share,
backend fork). Mochi aotir is shared across MEP-45/46/47/48/49/50;
the kotlin-specific lowering is in transpiler3/kotlin/lower/. The
per-target FFI annotation pattern.
What we reject. Scala-specific complexity (implicits, type classes, macros). Mochi's type system is deliberately simpler; the Kotlin emit shape is correspondingly simpler.
10. Compose Compiler plugin
What it is. The Jetpack Compose Compiler (developer.android.com/
jetpack/androidx/releases/compose-compiler) is a Kotlin compiler plugin
that transforms @Composable functions, adding skipping logic and
restart scopes. Critical for performant declarative UI on Android and
iOS via Compose Multiplatform.
How it works. As a Kotlin compiler plugin, it hooks into the IR
phase of the K2 compiler. Transforms @Composable fun MyButton() { ... }
into a function that participates in Compose's recomposition
infrastructure.
Lesson for MEP-50. Two indirect lessons. First, Kotlin's compiler plugin architecture is powerful but couples plugin authors to internal compiler ABIs, which churn release-to-release. This is exactly the brittleness MEP-50 avoids by emitting Kotlin source rather than authoring a compiler plugin. Second, Compose's component model (immutable data, render functions, dispatcher-based async) maps very cleanly onto Mochi's record-and-stream model, suggesting that Mochi+Compose UI integration is a strong v2 candidate.
What we take. The architectural caution: we are not a Kotlin compiler plugin. We are above the Kotlin compiler. This insulates us from compiler-plugin ABI churn.
What we reject. Authoring our own compiler plugin (for either the Compose-style or any other transform). Stays a v2 candidate; see 12-risks-and-alternatives A6.
11. Sibling Mochi backends, diff matrix
This is the table that matters for understanding MEP-50's position.
| Aspect | MEP-45 (C) | MEP-46 (BEAM) | MEP-47 (JVM bytecode) | MEP-48 (.NET) | MEP-49 (Swift) | MEP-50 (Kotlin) |
|---|---|---|---|---|---|---|
| Target form | C source + cc | Core Erlang | JVM bytecode (direct) | C# source + Roslyn | Swift 6 source + swiftc | Kotlin 2.1 source + kotlinc |
| Build tool | host cc / zig | rebar3 | none (direct) | dotnet | swift / xcodebuild | Gradle + KGP |
| Concurrency | OS threads | BEAM processes + OTP | Loom virtual threads | TPL / async-await | actor + AsyncStream | coroutines + Channel |
| Agents | thin lib | gen_server (OTP) | StructuredTaskScope | TaskScheduler | actor + AsyncStream | custom Channel-based class |
| Streams | callbacks | gen_event | Flow.Publisher | IAsyncEnumerable | AsyncStream / AsyncSequence | Flow |
| Record | C struct | tagged tuple | Java record | C# record | Swift struct | Kotlin data class |
| Sum type | C tagged union | tagged tuple | sealed interface (Java 17) | discriminated union | Swift enum | Kotlin sealed interface |
| Optional | tagged ptr | atom nil | java.util.Optional | C# Nullable | Swift Optional | Kotlin nullable T? |
| Result | tagged ptr | {:ok, v} / {:error, e} | sealed interface | sealed C# record | Swift Result | custom MochiResult |
| Typed throws | error code | throw atom | unchecked exception | exception | typed throws (SE-0413) | MochiResult return |
| String | char* (UTF-8) | binary | java.lang.String (UTF-16) | System.String (UTF-16) | Swift String (UTF-8) | Kotlin String (UTF-16) |
| Collections | hand-rolled | erlang:lists, maps | java.util.HashMap | System.Collections.Generic | Array, OrderedDictionary | LinkedHashMap, LinkedHashSet |
| Single-binary deploy | yes (cc) | no (BEAM VM req) | yes (jpackage + AppImage) | yes (NativeAOT) | yes (Static Linux SDK) | yes (K/Native) |
| Mobile reach | iOS/Android via FFI | none | Android (D8/R8) | iOS via MAUI | iOS, watchOS, visionOS | Android (AGP), iOS (K/Native) |
| Browser reach | none | none | TeaVM (3rd party) | Blazor WebAssembly | swift-wasm (alpha) | Kotlin/JS, Kotlin/Wasm |
| App store reach | none | none | Google Play | App Store (.NET MAUI) | App Store (.ipa) | Google Play (.aab) + App Store (K/Native) |
Where MEP-50 most resembles MEP-47: same JVM ecosystem, same JIT, same Maven Central. Where MEP-50 most resembles MEP-49: same source- codegen-then-vendor-compiler pattern, same multi-platform reach via managed-runtime LLVM (K/Native vs Apple's compiler), same app-store distribution gates.
Where MEP-50 diverges from everyone:
- It is the only target that reaches every major platform from a single codegen pass: JVM, Android, iOS, macOS, Linux, Windows, browser (JS), browser (Wasm). MEP-49 misses Android and Wasm. MEP-47 misses iOS, Wasm, and Linux/Windows native. MEP-48 misses Wasm-native and has weaker iOS story.
- It is the only target with an emerging Wasm story (K/Wasm Alpha).
- It is the only target where Compose Multiplatform is a viable v2 UI story spanning all major OSes (Android, iOS, macOS, Linux, Windows, browser).
12. Lessons synthesised
What MEP-50 takes from prior art:
-
Source emit, not bytecode (J2K, J2CL, Scala.js, dukat). The penalty in build time (kotlinc roundtrip) is worth the reach across five Kotlin targets.
-
KotlinPoet as the source IR (Square's tool, widely used). Saves us writing a Kotlin syntax tree.
-
Per-target FFI annotations (Scala.js
@JSExport, Scala Native@extern, Java JNI). Maps onto Kotlin'sexternal,@CName, JNIexternal funper target. -
Idiomatic output style (J2K's gold standard). The user reading our emitted Kotlin should see code indistinguishable from hand-written Kotlin.
-
Dead-code elimination friendliness (J2CL's discipline). Our runtime library and emitted code are R8/ProGuard friendly.
-
No compiler-plugin coupling (Compose's brittleness lesson). We stay above the Kotlin compiler.
-
Multi-target build via Gradle (KMP convention). We do not reinvent the build tool; we generate a Gradle KMP project.
-
Shared frontend, per-target backend (Scala.js / Scala Native pattern). Mochi
aotiris shared;transpiler3/kotlin/lower/is the per-target lowering.
What MEP-50 rejects:
-
PSI-to-PSI transformation (J2K's architecture). We have
aotir; we do not parse Kotlin to do the transform. -
TeaVM-style intermediate-bytecode path (Java bytecode to JS via 3rd party). We use the official Kotlin/JS and Kotlin/Wasm targets.
-
Authoring a Kotlin compiler plugin (Compose's coupling). Source emit only.
-
Direct FIR / KSP API consumption. Internal Kotlin compiler APIs are too unstable.
-
Closure Compiler dependency (J2CL's discipline). We rely on Kotlin's built-in DCE plus R8 / ProGuard.
-
Scala-style implicits, type classes, macros. Mochi is simpler; the Kotlin emit shape is simpler.
13. Cross-references
- 01-language-surface: the Mochi surface Kotlin must lower.
- 02-design-philosophy: the design decisions that came out of this survey.
- 04-runtime: the MochiRuntime KMP library, the layer above the emitted code.
- 05-codegen-design: the codegen pass that consumes
aotirand produces KotlinPoet files. - 06-type-lowering: per-type Kotlin lowering details.
- 07-kotlin-target-portability: per-Kotlin-target portability notes.
- 10-build-system: Gradle + KGP integration.
- 12-risks-and-alternatives: alternatives we rejected.
- [[../0049/03-prior-art-transpilers]]: the Swift sibling note, similarly surveying source-to-Swift prior art.
- [[../0048/03-prior-art-transpilers]]: the .NET sibling note, including a parallel survey of Roslyn-based source emission.
- [[../0047/03-prior-art-transpilers]]: the JVM-bytecode sibling note, including the comparison to direct-bytecode generation.