Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlinize Is Not Just Converting to Kotlin

Kotlinize Is Not Just Converting to Kotlin

Talk at KotlinLeeds2026

I've spent the past few years migrating Java to Kotlin in a decade-old Android codebase. Modern converters (IDE features, AI agents) can produce logically equivalent Kotlin code in seconds. So why does my team still spend hours on each file?

Because converting and Kotlinizing are not the same thing. A converter turns syntax into Kotlin. It does not decide whether an abstract class should survive the migration or be replaced with composition. It does not ask whether your validator should keep returning resource-backed error strings, or expose a sealed ValidationError for the UI layer. It just gives you Kotlin that compiles—and nothing more.I've shipped code like that and watched it haunt code reviews for months.The compiler was happy. Nothing else was.

This talk walks through what I learned. I'll walk through two real refactors from production: a small enum-based validator that hides an Android dependency, and an abstract class whose Java-era seams resist a clean Kotlin rewrite. For each, I'll show the conversion the tool produced, the version we shipped first, and the version we wish we'd shipped. You won't leave with a checklist. You'll leave able to make these decisions yourself, on your own code, the same way I now do on mine. Conversion is fast now. The judgment behind it is what compounds: across teammates, across migrations, across whatever ends up writing our code next.

Avatar for Kazuki Chigita

Kazuki Chigita

October 15, 2026

More Decks by Kazuki Chigita

Other Decks in Programming

Transcript

  1. 15 October 2026 @ KotlinLeeds Kotlinize is not just converting

    to Kotlin Kazuki CHIGITA (@chigichan24) Kodee, the Kotlin mascot, by JetBrains. 1
  2. About me Kazuki CHIGITA, from Japan Software Engineer @ MIXI,

    Inc. Building a long-lived family photosharing app Favourite Kotlin features: sealed interface, firstNotNullOfOrNull 2
  3. Java code public final class PasswordValidator { private final Context

    context; public PasswordValidator(Context context) { this.context = context; } public String getError(String input) { for (PasswordRule rule : PasswordRule.values()) { if (rule.isViolatedBy(input)) { return context.getString(rule.getMessageResId(), rule.getFormatArgs()); } } return null; } } 4
  4. Convert Java to Kotlin now has two modes Refactor ›

    Static Analysis ⌥⇧⌘K Mark File As › AI-Assisted Local History › Convert Java to Kotlin › Ah, the good old J2K. 5
  5. Kotlin, by static analysis class PasswordValidator(private val context: Context) {

    fun getError(input: String?): String? { for (rule in PasswordRule.entries) { if (rule.isViolatedBy(input)) { return context.getString(rule.getMessageResId(), *rule.getFormatArgs()) } } return null } } Hmm… it's just Java in Kotlin syntax. 6
  6. Now the AI-Assisted mode 1. Pick a model Bring your

    own API key, or use a local endpoint. Source: jetbrains.com/help/ai-assistant/settings-reference-java-to-kotlin.html 8
  7. 2. Add the skill kotlin-tooling-java-to-kotlin  CONVERSION-METHODOLOGY.md ### Step 2:

    Nullability & Mutability Only express types as non-null where you are sure that it can never be null, inferred from the original Java. Sounds right to me! Source: github.com/Kotlin/kotlin-agent-skills 9
  8. Kotlin, AI-Assisted class PasswordValidator(private val context: Context?) { Wow, this

    actually looks like Kotlin! fun getError(input: String?): String? = PasswordRule.entries .firstOrNull { it.isViolatedBy(input) } ?.let { context!!.getString( it.messageResId, *it.formatArgs, ) } } 10
  9. AI-Assisted One click. Ship it?  Compiles  Tests pass

     Looks like Kotlin Merge pull request 11
  10. It can ship, but… 1. Context? class PasswordValidator(private val context:

    Context?) { fun getError(input: String?): String? = Can it really be null? PasswordRule.entries .firstOrNull { it.isViolatedBy(input) } ?.let { 2. String? (return) context!!.getString(…) Null still means "no error". } } 3. input: String? 4. context!! Then why force it with !!? Does anyone ever pass null? 12
  11. Two kinds of converters: static and AI Static Analysis AI-Assisted

    Walks the syntax tree Writes what Kotlin looks like raw.split("\n".toRegex()) .dropLastWhile { it.isEmpty() } class PasswordValidator( private val context: Context?) { .toTypedArray() … PasswordRule.entries.firstOrNull { … } line.trim { it <= ' ' }.isEmpty() Faithful to the letter. Nobody writes Kotlin like this. ?.let { context!!.getString(…) } Looks idiomatic. Small cracks everywhere. Different strengths. Neither gets it right. 13
  12. Kotlin, written by hand class PasswordValidator(private val context: Context) {

    fun getError(input: String): String? = PasswordRule.entries Hmm… is this really what we want? .firstOrNull { it.isViolatedBy(input) } ?.let { context.getString(it.messageResId, *it.formatArgs) } } Context is never null. Input is never null. Neither fact is in the Java file. Better Kotlin. But it still says "null means no error". 14
  13. Code is frozen intent The app around it moved on.

    This code still says what we meant ten years ago. Today ten years ago Today LoginScreen.kt UserRepository.kt @Composable fun LoginScreen() suspend fun fetchUser(): User LoginViewModel.kt getError( public String String input) { for (PasswordRule r : val state: StateFlow<UiState> values()) if (r.isViolatedBy(…)) return context…; return null; UiState.kt sealed interface UiState } Navigation.kt NavHost(navController, …) build.gradle.kts alias(libs.plugins.compose) 15
  14. Has the intent changed?  No  Yes Convert faithfully.

    Unpack the intent first. Static Analysis is fine. Then rewrite it in Kotlin. public interface Clock { long now(); } ↓ Static Analysis, as is interface Clock { fun now(): Long } Usually yes, because these two drift:  Product intent  Expressiveness 16
  15. Ten years on, two things have drifted  Product intent

     Expressiveness Then Then String getError(…) // null Callback<User> Observable<User> extends BaseRepository Now sealed interface PasswordError { data object Empty data class TooShort(val min: Int) } suspend fun fetch(): User val user: Flow<User> class Repo(c: Cache) : Cache by c •••••• Password must contain a digit. Now ••••••    At least 8 characters A digit An uppercase letter 17
  16. Let's dig into two real cases   Case #1

    Case #2 The validator The base repository Where product intent drifted Where expressiveness drifted 18
  17. Three rules fail. You see one. Create account Email [email protected]

    Password  Password abc abc Password must be at least 8 characters. Password must be at least 8 characters.     EMPTY passed TOO_SHORT shown NO_DIGIT hidden NO_UPPERCASE hidden Sign up 20
  18. Two small files PasswordRule.java PasswordValidator.java enum PasswordRule { String getError(String

    input) { EMPTY(R.string.err_empty) { … }, for (PasswordRule r : PasswordRule.values()) { TOO_SHORT(R.string.err_short) { … }, if (r.isViolatedBy(input)) NO_DIGIT(R.string.err_digit) { … }, return context.getString(r.msgId); NO_UPPERCASE(R.string.err_upper) { … }; } return null; abstract boolean isViolatedBy(String in); } } Try them in order. First failure wins. Each rule: a check and a message 21
  19. Ten years ago, the order was the intent Checked top

    to bottom. The first failure is the only message. 1 EMPTY(R.string.err_empty) Nothing typed? Say that first. 2 TOO_SHORT(R.string.err_short) Then: length. 3 NO_DIGIT(R.string.err_digit) Then: a digit. 4 NO_UPPERCASE(R.string.err_upper) Last: an uppercase letter. shown for "abc" I see! The order is the product’s priority. Nowhere is this written down. It lives in the order of four lines. 22
  20. One order, two jobs Java: EMPTY must stay first EMPTY(…)

    { boolean isViolatedBy(String in) { J2K: the build broke abstract fun isViolatedBy( input: String?) return TextUtils.isEmpty(in); // null OK } override fun isViolatedBy( }, input: String) TOO_SHORT(…) { // overrides nothing boolean isViolatedBy(String in) { return in.length() < 8; // null: crash } } Change the product’s order, and the null guard falls with it. 23
  21. From one message to every rule Ten years ago Today

    Password Password abc abc Password must be at least 8 characters. layout.setError(validator.getError(text))    At least 8 characters A digit An uppercase letter RuleChecklist(validator.errorsFor(text)) 24
  22. What we shipped first Kotlin, old intent class PasswordValidator( 1

    context: Context 1 Context in the logic Every test needs a fake Context. ) { fun getError(input: String?): String? = 2 PasswordRule.entries .firstOrNull { it.isViolatedBy(input) } ?.let { context.getString(…) } 2 null means success The happy path has no name. } 25
  23. Say the intent precisely Kotlin, today’s intent fun parsePassword(input: String):

    PasswordResult { 1 1 val errors = PasswordRule.entries Null stops at the door No rule leans on the order. .filter { it.isViolatedBy(input) } 2 return if (errors.isEmpty()) Valid(Password(input)) } Every rule stands alone enum class PasswordRule(val priority: Int) { … } 3 filter, not firstOrNull. else Invalid(errors) 2 data class Invalid( val errors: List<PasswordRule> ) : PasswordResult { val mostImportant = errors.minBy { it.priority } 3 Priority has a name Reorder the enum; nothing changes. } 26
  24. Now the UI change is one line Ten years ago

    Today Password Password abc abc Password must be at least 8 characters. showError(result.mostImportant)    At least 8 characters That’s it? One line? A digit An uppercase letter showChecklist(result.errors) No Android in the logic, so only the UI changes. The logic itself changed once: firstOrNull became filter. 27
  25. Rich Errors are coming Proposal · KEEP-0462 · syntax may

    change error class Invalid(val errors: List<PasswordRule>) fun parsePassword(input: String): Password | Invalid when (val result = parsePassword(text)) { is Password -> save(result) is Invalid -> showChecklist(result.errors) } Success and failure both live in the signature. Source: github.com/Kotlin/KEEP/discussions/487 28
  26. Every repository is a BaseRepository Caching, threads and callbacks live

    in one place. Everything else plugs in. BaseRepository<T> cache · TTL · threads · callbacks extends User Article Photo Album Comment Notification Repository Repository Repository Repository Repository Repository One parent, every repository in the app. 30
  27. The comments are warnings BaseRepository.java public abstract class BaseRepository<T> {

    protected BaseRepository(…) { // Subclass fields are not initialized yet at this point. this.cacheKey = cacheKey(); } /** Called from the constructor; must not depend on subclass state. */ protected abstract String cacheKey(); /** Called on the executor thread, before the callback. */ protected void onFetched(T data) {} … } The base class has to warn you. What the constructor may call. Which thread your hook runs on. class UserRepository extends BaseRepository<User> {…} class ArticleRepository extends BaseRepository<List<Article>> { … } Source: Joshua Bloch, Effective Java (3rd ed.), Items 18 and 19 31
  28. Ten years of “just one more hook” PhotoRepository) Year 9

    if (this instanceof Year 7 onFetchedOnMainThread() Year 5 boolean skipCache Year 4 int retryCount // photos only Year 2 protected void onFetched() Year 1 cache + TTL Nope. I’m not touching that. BaseRepository<T> Every fix lands in the base class. Every subclass inherits the risk. 32
  29. Is this class open on purpose? BaseRepository.kt · J2K abstract

    class BaseRepository<T> protected constructor( protected val apiClient: ApiClient, … ) { protected var cache: T? = null protected var lastFetchedAt: Long = 0 protected abstract fun cacheKey(): String? In this file: protected ×12 abstract ×5 open ×1 protected abstract fun endpoint(): String? protected abstract fun parse(raw: String?): T? protected open fun onFetched(data: T?) {} } Kotlin classes are final by default. This one was built to be extended. Source: kotlinlang.org/docs/inheritance.html 33
  30. From inheritance to composition Ten years ago Today UserRepository is

    a BaseRepository UserRepository has a CachedResource UserRepository UserRepository cacheKey() endpoint() parse() onFetched() BaseRepository<T> cache · TTL · threads · callbacks Built on top. Four hooks, called in an order you have to know. CachedResource { parseUser(api.get(…)) } Owned inside. What varies is one function. 34
  31. What we shipped first Kotlin, old intent abstract class BaseRepository<T>(

    1 1 protected val api: ApiClient, Still a base class Every new repository reads it first. ) { val cacheKey = cacheKey() 2 protected var cache: T? = null suspend fun fetch(force: Boolean): T = … protected abstract fun cacheKey(): String protected abstract fun parse(raw: String): T 2 An init-order trap cacheKey() runs before the subclass is ready. } Source: kotlinlang.org/docs/inheritance.html#derived-class-initialization-order 35
  32. Say the intent precisely Kotlin, today’s intent class CachedResource<T>( 1

    private val ttl: Duration, Has a cache Composition, not inheritance. private val fetch: suspend () -> T, 2 ) { suspend fun get(force: Boolean = false): T } 2 class UserRepository(api: ApiClient, id: String) { What varies is a function suspend () -> T, nothing else. private val user = 1 CachedResource(5.minutes) { parseUser(api.get("/users/$id")) } 3 Nothing protected No hooks, no call order to learn. suspend fun get(): User = user.get() 3 } 36
  33. Adding a repository, then and now Ten years ago class

    PhotoRepository : BaseRepository<…>(…) { override fun cacheKey() = "photos" Today private val photos = CachedResource(5.minutes) { override fun endpoint() = "/photos" retry(times = 3) { override fun parse(raw: String) = … parsePhotos(api.get("/photos")) init { retryCount = 3 } // read by the base class } First, read the base class. The retry goes in there too. } } Read nothing else. The retry stays right here. Kotlinize is when you finally untangle it. 37
  34. What the cases taught us ↩ Chapter 1 ↩ Case

    #1 ↩ Case #1 · Case #2 It can ship, but… The order was the intent J2K: the build broke context: Context? context!!.getString(…) 1 EMPTY 2 TOO_SHORT 3 NO_DIGIT … override fun isViolatedBy( input: String) // overrides nothing AI makes it look right. The cracks stay: Context?, !!, null for success. The intent lives outside the file. Types surface hidden intent. A product priority. Ten years of hooks. A broken build. A class built to be extended. 39
  35. Split the work  You  AI  Types &

    tests Decide the intent: what changes, what stays. Write the code: conversion, idioms, boilerplate. Keep you both honest. ↩ Case #1 ↩ Case #1 The order was a product decision. → ↩ Chapter 1 entries, firstOrNull, let. For free. → The build broke on a hidden rule. ↩ Case #2 ↩ Case #2 Keep the base class, or compose? Final by default. Is this open on purpose? 41
  36. Give it the intent, not just the file File only

    File + intent   You Convert PasswordValidator.java to Kotlin. You Convert it to Kotlin. Context is never null. The product now shows every failed rule. Keep Android out of the logic. ↓ ↓ ↩ Chapter 1 ↩ Case #1 class PasswordValidator( private val context: Context?) { fun getError(input: String?): String? = … context!!.getString(…) } fun parsePassword(input: String): PasswordResult sealed interface PasswordResult { data class Valid(val password: Password) data class Invalid(val errors: List<PasswordRule>) } And keep the conversion and the redesign in separate pull requests. 42
  37. For now, humans own readability and cognitive load Next year?

    Nobody knows. Especially in products where old code still lives. 43
  38. Kotlin isn’t the goal. It’s where intent becomes checkable. AI

    gives you this for free You add this. We’ve been doing it all talk Code that looks like Kotlin context: Context A fact from outside the file input: String Null stops at the door ↩ Case #1 sealed interface PasswordResult Success has a name ↩ Case #1 val priority: Int Priority has a name ↩ Case #1 class … // final Extend on purpose, or not at all ↩ Case #2 fetch: suspend () -> T What varies is a function ↩ Case #2 PasswordRule.entries .firstOrNull { … } Intent, written in types ↩ Chapter 1 ?.let { … } For people, types say the intent out loud. For AI, they catch its slips. 44
  39. Three questions behind every decision 1 2 3 Has the

    intent changed? What is today’s intent? Where will it live? If not, convert faithfully. J2K is enough. Ask the product and the team. It isn’t in the file. In types. Then the next change, and the next AI, can’t miss it. ↩ Clock interface Clock { fun now(): Long } ↩ Case #1 ↩ Case #1 · Case #2 The order was a product priority. The UI change is one line. The retry stays put. 47
  40. Conversion is fast now. Thank you! The judgment behind it

    is what compounds. Across teammates, across migrations, across whatever ends up writing our code next. 48