Why translate everything?

Meta’s Android codebase has been Kotlin-first since 2020, but being Kotlin-first doesn’t automatically mean being Kotlin-only. Many companies stop at writing new code in Kotlin and leave existing Java untouched. Meta decided that the full productivity and null-safety benefits of Kotlin would only materialize if roughly ten million lines of existing Java were rewritten. That decision meant building infrastructure to automate translation at scale rather than relying on manual effort.

The reasoning for translating actively developed code is straightforward: that’s where developer productivity gains pay off. The less obvious part is why translating central-but-idle code also matters for null safety. Every remaining Java file is a potential source of nullability chaos, especially if it sits high in the dependency graph. Mixed codebases also carry operational costs: parallel tool chains need ongoing support, and compiling Java and Kotlin together is slower than compiling either alone.

From IDE clicks to automated pipelines

The initial migration strategy was unglamorous but familiar: engineers repeatedly clicked the J2K conversion button in IntelliJ. That approach caps out around 100,000 manual conversions for a codebase Meta’s size — each click followed by a multi-minute wait. To escape that bottleneck, Meta built the Kotlinator, a wrapper around J2K that runs the conversion unattended in six phases:

  1. Deep build: Compiling the target code first lets the IDE resolve all symbols, including those from third-party dependencies and generated code.
  2. Preprocessing: About 50 custom steps handle nullability, J2K workarounds, and Meta’s custom DI framework.
  3. Headless J2K: The standard J2K conversion, adapted for server use.
  4. Postprocessing: Roughly 150 steps apply Android-specific fixes, additional nullability adjustments, and idiomatic Kotlin cleanup.
  5. Linters: Autofix-enabled linters handle perennial fixes in both conversion diffs and regular development.
  6. Build error-based fixes: Failed builds of freshly converted code are parsed, and errors are corrected automatically — adding imports or inserting !! where needed.

The headless J2K phase was the first major hurdle. J2K is tightly coupled to the IntelliJ IDE, so Meta worked with JetBrains to find an approach that didn’t require a full IDE session. The solution was an IntelliJ plugin with a class extending ApplicationStarter that calls directly into the JavaToKotlinConverter class — the same code path the IDE’s conversion button uses. Going headless means conversions no longer block developers’ local IDEs, and multiple files can be translated in one pass. A typical remote conversion takes about 30 minutes, but that’s time spent by a server, not a developer.

Who decides what gets converted and when? Meta’s internal diff system supports cron-like jobs that generate a daily batch of diffs based on user-defined criteria. The system assigns reviewers, runs validations, and ships once a human approves. A web UI also lets developers trigger remote conversions of specific files or modules. Beyond prioritizing actively developed files, there’s no enforced translation order — the Kotlinator can handle most compatibility changes to dependent files, such as converting foo.getName() references to foo.name, without requiring dependency-graph ordering of diffs.

Custom pre- and post-conversion steps

Vanilla J2K output rarely built cleanly in Meta’s codebase, given its scale and custom frameworks. The Kotlinator’s preprocessing and postprocessing phases exist specifically to bridge that gap. Each phase contains dozens of steps that analyze the file being translated — and sometimes its dependencies and dependents — and apply Java-to-Java or Kotlin-to-Kotlin transformations where needed.

These steps run on an internal metaprogramming tool built on JetBrains’ PSI libraries for both Java and Kotlin. It’s deliberately not a compiler plugin, which lets it analyze broken code across both languages quickly. That speed matters in postprocessing, which often deals with code that has compilation errors while still needing type information. Some steps examine an interface’s Kotlin implementers across thousands of unbuildable files to update overridden getter functions into overridden properties:

interface JustConverted {
  val name: String // I used to be a method called `getName`
}
class ConvertedAWhileAgo : JustConverted {
  override fun getName(): String = "JustConvertedImpl"
}
class ConvertedAWhileAgo : JustConverted {
  override val name: String = "JustConvertedImpl"
}

The trade-off for this speed and flexibility is that the tool sometimes can’t determine type information, particularly when symbols come from third-party libraries. In those cases it bails out visibly rather than risking an incorrect transformation. The resulting Kotlin may not build, but the fix is usually obvious to a human.

Meta originally built these phases to cut manual effort, but found another benefit: reducing human error. Some delicate transformations are safer in bot hands. For example, condensing long chains of null checks produces Kotlin that isn’t more correct, but the automated version is less likely to lose a ! or misplace a ? in the process than a well-meaning developer making manual edits.

Letting compiler errors drive fixes

Early conversions spent significant time at the end of the pipeline — building, reading compiler errors, fixing, and rebuilding. Many of those fixes could theoretically be preempted in postprocessing, but that would mean reimplementing logic already baked into the Kotlin compiler. Instead, the Kotlinator’s final phase consumes compiler error messages the same way a human would, applying fixes through the same metaprogramming tools that can analyze unbuildable code. This approach handles error-driven corrections without duplicating compiler logic.

When more steps stop helping

The Kotlinator now has well over 200 custom steps across its phases, but some conversion issues can’t be papered over with additional transformations. Meta initially treated J2K as a black box — it was open source, but complex and not actively developed, so contributing patches didn’t seem worthwhile. That changed in early 2024 when JetBrains started updating J2K to be compatible with the new Kotlin compiler, K2. Meta used that collaboration to fix persistent problems, such as disappearing override keywords during conversion, and to add hooks into J2K that let clients run their own custom steps directly in the IDE pre- and post-conversion.

Porting existing steps to leverage J2K’s extension points offers two practical advantages. First, symbol resolution improves: J2K’s resolution is more precise than Meta’s custom approach, especially for third-party symbols, and it unlocks IntelliJ’s more sophisticated static-analysis tooling. Second, it enables easier open sourcing and cross-company collaboration. Many of Meta’s custom steps are too Android-specific for core J2K but would be useful elsewhere — yet they depend on Meta’s custom symbol resolution. Reworking them to rely on J2K’s resolution removes that dependency and creates an opportunity to share the work.

Null-safe Java still leaks NPEs

Static analysis in Nullsafe is only fully reliable at 100% code coverage, which is unrealistic for large mobile codebases that talk to servers and third-party libraries. A single non-null-safe caller can defeat the guarantees of otherwise well-annotated code.

Consider a small class:

@Nullsafe
public class MyNullsafeClass {

  void doThing(String s) {
    // can we safely add this dereference?
    // s.length;
  }
}

If MyNullsafeClass has a dozen dependents and just one of them is not null-safe, a call like MyNullsafeJava().doThing(null) becomes possible. Inserting a dereference in the method body then creates an NPE where none existed before. The damage scales with how many non-null-safe callers exist, and central dependent nodes amplify the risk considerably.

Kotlin adds runtime enforcement

Kotlin differs from Nullsafe Java in a fundamental way: it inserts runtime validation at the interlanguage boundary. This lets developers trust annotations in code they modify, because bytecode-level checks back them up.

Translating the example class to Kotlin gives:

class MyNullsafeClass {

  fun doThing(s: String) {
    // there's an invisible `checkNotNull(s)` here in the bytecode
    // so adding this dereference is now risk-free!
    // s.length
  }
}

An invisible checkNotNull(s) sits at the start of doThing. Adding a dereference of s is then safe, because a nullable value would already have crashed there. That certainty dramatically simplifies refactoring and maintenance.

Kotlin's compiler also enforces stricter static rules around concurrency. It rejects dereferences of class-level properties that another thread could have nulled out, something Nullsafe permits. This leads to more !! than expected when porting Nullsafe code, though the practical impact is minor.

The cost of clarity

Removing ambiguity is not free. Someone—usually the developer or the conversion bot—must take the risk of adding an implicit non-null assertion when translating a parameter like s. Meta's Kotlinator mitigates this by defaulting toward nullable types when context is unclear. In the example above, the absence of dereferences in the method body causes String s to become s: String?.

Reviewers pay special attention to !! that appears outside existing dereferences. foo!!.name is fine, because it is no more likely to crash than the original Java. But someMethodDefinedInJava(foo!!) is suspect; the Java method might simply be missing a @Nullable annotation, and the !! would introduce a wholly avoidable NPE.

To reduce such mistakes, Meta runs more than a dozen complementrary codemods that scan for parameters, return types, and member variables missing @Nullable. Better annotation coverage across the whole codebase—including Java files that may never be translated—makes each conversion safer.

The hardest nullability issues were never solved by static analysis alone. Meta built a Java compiler plugin that collects runtime data on every parameter and return type that actually receives or produces a null without being annotated. Combined with codemods, this resolves misannotations at their true source.

Beyond nullability

Null safety is not the only conversion hazard. Over 40,000 shipped conversions have surfaced many others, now guarded by multiple validation layers. Two notable patterns:

Initialization vs. getter calls

// Incorrect!
val name: String = getCurrentUser().name

// Correct
val name: String
  get() = getCurrentUser().name

Nullable boolean traps

// Original
if (foo != null && !foo.isEnabled) println("Foo is not null and disabled")

// Incorrect!
if (foo?.isEnabled != true) println("Foo is not null and disabled")

// Correct
if (foo?.isEnabled == false) println("Foo is not null and disabled")

What remains

More than half of Meta's Android Java code is now Kotlin (or deleted)—but that was the straightforward half. Thousands of fully automated conversions are still blocked on new or improved custom steps in Kotlinator and upsteam J2K work. Thousands more need semi-automated handling with careful safety checks.

These problems affect any company translating a large Android codebase. Meta has published its tooling as open source and welcomes contributions, with discussion happening in the #j2k channel of the Kotlinlang Slack.