Smartcast and Null Safety: Influences from Kotlin to TS

Felipe Hlibco

Working at Google means living in a polyglot environment. Android teams write Kotlin. Web teams write TypeScript. Backend teams write a mix of Java, Go, and (increasingly) Kotlin. When you cross those boundaries regularly — and in DevRel, crossing boundaries is the job — you start noticing how ideas migrate between language ecosystems.

One migration that doesn’t get discussed enough: the convergence of type narrowing between Kotlin and TypeScript.

Kotlin’s smart casts #

Kotlin shipped smart casts from day one in 2016. The idea was simple and, at the time, felt radical compared to Java: if you check a type at runtime, the compiler should remember that check and narrow the type automatically.

fun processInput(input: Any) {
    if (input is String) {
        // Kotlin knows 'input' is a String here
        println(input.length) // No explicit cast needed
    }
}

No (String) input. No input as String. The compiler tracks the control flow, sees the is check, and narrows the type within the branch. The same applies to null checks:

fun greet(name: String?) {
    if (name != null) {
        // name is automatically narrowed to String (non-null)
        println("Hello, ${name.length}")
    }
}

This was Kotlin’s answer to Java’s verbosity problem. In Java, you’d check instanceof, then cast — two operations that express the same intent but require separate syntax. Kotlin unified them. The result is code that reads like what you mean, not like what the compiler needs to hear.

TypeScript’s control flow analysis #

TypeScript 2.0, also released in 2016 (coincidence?), introduced control-flow-based type analysis that works remarkably similarly. Type guards narrow types within conditional branches:

function processInput(input: unknown) {
    if (typeof input === "string") {
        // TypeScript knows 'input' is a string here
        console.log(input.length); // No assertion needed
    }
}

And null checks narrow union types the same way:

function greet(name: string | null) {
    if (name !== null) {
        // name is narrowed to string
        console.log(`Hello, ${name.length}`);
    }
}

The parallel is striking. Both languages arrived at nearly identical solutions to the same problem: how do you make type safety feel natural rather than ceremonious? Both answered: trust the developer’s runtime checks and let the compiler follow the control flow.

The mutable variable problem #

Both languages also ran into the same hard problem, which tells you something about the inherent difficulty of the approach.

var name: String? = "Felipe"

fun checkName() {
    if (name != null) {
        // Kotlin: Smart cast impossible --- 'name' is a mutable property
        // that could have been changed by another thread
        println(name.length) // Compile error!
    }
}

Kotlin refuses to smart-cast mutable properties (vars) because another thread could modify the value between the null check and the usage. TypeScript faces a similar challenge with closures and reassignments, though it handles some cases more permissively because JavaScript’s single-threaded execution model reduces (but doesn’t eliminate) the race condition risk.

The solutions differ. Kotlin encourages val (immutable bindings) or local variable captures. TypeScript relies on flow analysis that tracks assignments within the same scope. Both are imperfect. Both make pragmatic trade-offs between safety and ergonomics.

This shared stumbling block is evidence that smart casting isn’t just a feature — it’s an emergent property of type systems that try to be both sound and usable. The mutable variable problem is where soundness and usability collide, and every language that attempts smart casts has to pick its compromise.

The narrowing gap that TypeScript closed #

Kotlin had a head start in some areas. Its sealed classes combined with when expressions created exhaustive type narrowing that TypeScript didn’t match until discriminated unions became idiomatic:

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
}

fun handle(result: Result) = when (result) {
    is Result.Success -> println(result.data)
    is Result.Error -> println(result.message)
    // Compiler enforces exhaustiveness --- no 'else' needed
}

TypeScript achieved similar exhaustiveness checks with discriminated unions and never:

type Result =
  | { kind: "success"; data: string }
  | { kind: "error"; message: string };

function handle(result: Result) {
    switch (result.kind) {
        case "success": return console.log(result.data);
        case "error": return console.log(result.message);
        // TypeScript: adding a new variant without a case is a compile error
        //            (with strictNullChecks and exhaustiveness checking)
    }
}

But TypeScript had a notable gap for years: narrowing through aliased conditions. If you stored the result of a type check in a variable and then used that variable in a conditional, the narrowing didn’t follow. TypeScript 4.4 fixed this in 2021, bringing aliased conditional narrowing that Kotlin had handled from the beginning.

function process(input: unknown) {
    const isString = typeof input === "string";
    if (isString) {
        // Before TS 4.4: input is still 'unknown'
        // After TS 4.4: input is narrowed to 'string'
        console.log(input.toUpperCase());
    }
}

I wrote about TypeScript 4.9’s in operator improvements recently — that’s another step in this same progression. Each TypeScript release closes another narrowing gap, and the direction of travel consistently points toward Kotlin’s level of smart-cast sophistication.

Cross-pollination, not copying #

I want to be clear: TypeScript didn’t copy Kotlin, and Kotlin didn’t copy TypeScript. Both drew from earlier research (flow typing has academic roots going back decades) and arrived at similar solutions because the problem space constrains the design space. When your goals are “type safety + developer ergonomics + practical performance,” the set of viable solutions narrows considerably.

But cross-pollination is real. TypeScript team members have spoken publicly about studying Kotlin’s approach. Kotlin’s evolution has been influenced by features that proved popular in TypeScript’s ecosystem. The broader trend is that language designers watch each other closely and adopt ideas that work, regardless of which ecosystem originated them.

This convergence is healthy. When Kotlin, TypeScript, Swift, and Rust all independently arrive at some version of “the compiler should track runtime type checks through control flow,” that’s a strong signal that this is simply how modern type systems should work. The ceremony of explicit casts after type guards was never a feature; it was a limitation that the technology finally outgrew.

What this means for language choice #

If you’re choosing between Kotlin and TypeScript for a project (which, at Google, happens more than you’d think), the type narrowing story shouldn’t be a differentiator anymore. Both languages provide excellent smart-cast capabilities. Both handle null safety through their type systems rather than through runtime conventions.

The real differences lie elsewhere: ecosystem maturity for specific platforms, runtime characteristics, tooling, and team expertise. The type system convergence means that developers moving between Kotlin and TypeScript projects (as many do at Google) encounter fewer conceptual gaps than they would have five years ago.

For language design broadly, the lesson is that making type safety ergonomic isn’t a nice-to-have; it’s the difference between developers actually using the type system and finding ways to circumvent it. Kotlin and TypeScript both understood this from the beginning. Their convergence validates the approach.