DEV Community

Cover image for Kotlin `apply` / `also` / `with` Don't Translate to TypeScript
Gabriel Anhaia
Gabriel Anhaia

Posted on

Kotlin `apply` / `also` / `with` Don't Translate to TypeScript


A Kotlin developer joins a TypeScript codebase. They need a User with two fields set, so they write what their fingers know:

val user = User().apply {
    name = "alice"
    age  = 30
}
Enter fullscreen mode Exit fullscreen mode

They translate. There's no apply in TypeScript, so they reach for a builder:

class UserBuilder {
  private user: Partial<User> = {};
  setName(n: string) { this.user.name = n; return this; }
  setAge(a: number)  { this.user.age  = a; return this; }
  build(): User { return this.user as User; }
}

const user = new UserBuilder().setName("alice").setAge(30).build();
Enter fullscreen mode Exit fullscreen mode

The TypeScript developer on their team reviews the PR and writes their own version on a sticky note:

const user: User = { name: "alice", age: 30 };
Enter fullscreen mode Exit fullscreen mode

Then asks why we have a class.

This is the moment most JVM developers hit on day three or four of writing TypeScript. Kotlin's scope functions (let, also, apply, with, run) are daily vocabulary. Used a few times per file. Tied into how a Kotlin developer thinks about expressions and side effects. TypeScript ships none of them, and almost nobody misses them. That second fact is the one to internalise.

This post is the bridge. What each scope function does, why TypeScript got away without them, and the patterns that actually do the same jobs in TypeScript.

The five scope functions in 90 seconds

Kotlin's official scope-functions docs lay out five functions across two axes: how they refer to the receiver (this vs it) and what they return (the receiver vs the lambda result).

Function Receiver Returns Typical use
let it lambda result transform a value, scope a temp variable
also it the receiver side effect, log, peek
apply this the receiver configure an object after construction
with this lambda result call several methods on the same object
run this lambda result combine let + with for this access

run is the odd one out — it's a method on the receiver instead of a top-level function, and in practice you reach for let or with first. The other four cover almost every real Kotlin file:

// apply — configure
val user = User().apply {
    name = "alice"
    age  = 30
}

// also — peek without changing the value
val token = generateToken().also { logger.info("token issued: ${it.id}") }

// let — transform / null-safe scope
val nameLength = userOrNull?.let { it.name.length } ?: 0

// with — call methods on a given receiver
val summary = with(user) {
    "$name ($age)"
}
Enter fullscreen mode Exit fullscreen mode

Four idioms. They each replace a temp variable, chain cleanly, and read naturally because Kotlin's lambda-with-receiver gives you this-scoped configuration blocks Java couldn't.

A Kotlin developer who walks into TypeScript reaches for these on autopilot. There's no direct equivalent — there are four different patterns instead. The next four sections are what TypeScript does instead.

apply → object literal

apply in Kotlin almost always means "construct, then set fields". In TypeScript that's an object literal:

const user: User = {
  name: "alice",
  age: 30,
};
Enter fullscreen mode Exit fullscreen mode

That covers maybe 80% of the apply calls in a Kotlin codebase. Object literal init in TypeScript is structural, type-checked, and short. There's no User() to call first because there's no required class — User is a type, the {} is the value, and TypeScript matches them up structurally.

The remaining 20% are cases where you genuinely have a class with state and methods, and you need to construct then mutate. Two patterns cover that. The first is a constructor that takes the fields directly:

class User {
  constructor(public name: string, public age: number) {}
  greet() { return `Hi, ${this.name}`; }
}

const user = new User("alice", 30);
Enter fullscreen mode Exit fullscreen mode

The second is the constructor-takes-options pattern, which is what most TypeScript libraries do:

class HttpClient {
  constructor(private opts: { baseUrl: string; timeout?: number }) {}
}

const client = new HttpClient({
  baseUrl: "https://api.example.com",
  timeout: 5_000,
});
Enter fullscreen mode Exit fullscreen mode

You almost never see the Kotlin User().apply { ... } shape in idiomatic TypeScript. It only shows up when JVM developers write it.

apply for builders → method chaining returning this

Sometimes the configuration is genuinely staged. You build something across several lines, with conditional steps, before sealing it. For that, chain methods that return this. This is the one place where the Kotlin habit translates almost verbatim.

class QueryBuilder {
  private parts: string[] = [];

  select(cols: string)  { this.parts.push(`SELECT ${cols}`); return this; }
  from(table: string)   { this.parts.push(`FROM ${table}`);  return this; }
  where(pred: string)   { this.parts.push(`WHERE ${pred}`);  return this; }
  build(): string       { return this.parts.join(" "); }
}

const sql = new QueryBuilder()
  .select("id, name")
  .from("users")
  .where("age > 18")
  .build();
Enter fullscreen mode Exit fullscreen mode

That's the apply-for-builders pattern. Use it when there's real construction logic, conditional steps, or a .build() finalisation. Don't use it for plain data. That's what the object literal is for.

There's an even narrower variant where the chain returns the value being configured. Kotlin's also does this when you want to peek at a chain step:

val client = HttpClient(opts)
    .also { logger.info("client constructed") }
    .also { metrics.increment("client.created") }
Enter fullscreen mode Exit fullscreen mode

The TypeScript translation is the next pattern.

also → a 3-line tap helper

also returns the receiver. It exists for side effects in the middle of an expression. In TypeScript, that's a tap helper:

function tap<T>(value: T, fn: (v: T) => void): T {
  fn(value);
  return value;
}
Enter fullscreen mode Exit fullscreen mode

Three lines, no dependency, plays nicely with chains:

const token = tap(generateToken(), (t) => {
  logger.info(`token issued: ${t.id}`);
});

const client = tap(new HttpClient(opts), () => {
  logger.info("client constructed");
  metrics.increment("client.created");
});
Enter fullscreen mode Exit fullscreen mode

If you reach for tap more than a couple of times per module, it's worth a small utility module. Most TypeScript codebases live without it because the temp-variable form is also fine:

const token = generateToken();
logger.info(`token issued: ${token.id}`);
Enter fullscreen mode Exit fullscreen mode

That's two lines instead of one. Kotlin developers are trained to dislike named temps. TypeScript developers are trained the other direction. The named temp is more readable in their idiom because each line does one thing. The cultural difference is real.

If you're writing pipeline-style code where a tap-equivalent comes up constantly (RxJS chains, Effect workflows, fp-ts pipelines), those libraries ship a tap operator. The Effect docs on Effect.tap describe it the same way: run a side effect, return the original value. Same idea, library-scoped.

Object.assign for partial mutation

A Kotlin pattern adjacent to apply is patching an existing object:

existingUser.apply {
    name  = "bob"
    email = "bob@example.com"
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's equivalent is Object.assign, or, more idiomatically, spread into a new object:

// mutating, like apply
Object.assign(existingUser, { name: "bob", email: "bob@example.com" });

// non-mutating, the more common idiom
const updated = { ...existingUser, name: "bob", email: "bob@example.com" };
Enter fullscreen mode Exit fullscreen mode

The non-mutating version is what most TypeScript codebases prefer, especially in React and Redux-flavoured code where reference equality is part of how change detection works. The Kotlin habit of mutating-in-place doesn't carry over cleanly. The shape is the same; the choice between mutate and replace is the cultural switch.

with and run → the function expression

with in Kotlin lets you scope a block to a receiver and return whatever the block produces. run does the same thing as a method on the receiver. Both compress to the same TypeScript shapes:

val summary = with(user) {
    "$name ($age) — $email"
}
Enter fullscreen mode Exit fullscreen mode

That's destructuring in TypeScript:

const summary = (() => {
  const { name, age, email } = user;
  return `${name} (${age}) — ${email}`;
})();
Enter fullscreen mode Exit fullscreen mode

Or, much more often, inline access:

const summary = `${user.name} (${user.age}) — ${user.email}`;
Enter fullscreen mode Exit fullscreen mode

The this-scoping convenience of with doesn't survive the trip. JavaScript's with statement was banned in strict mode for being a typing nightmare. ES modules are strict by default and TypeScript modules inherit that, so the keyword exists in the grammar but you cannot use it in any modern TS code. Destructuring is the closest in spirit, and most code skips even that.

let → the immediately-invoked arrow

let is the one with the most direct syntactic equivalent. Kotlin uses let as a null-safe scope and as a temp-variable maker:

val nameLength = userOrNull?.let { it.name.length } ?: 0
Enter fullscreen mode Exit fullscreen mode

TypeScript covers most of this with optional chaining:

const nameLength = userOrNull?.name.length ?? 0;
Enter fullscreen mode Exit fullscreen mode

When you genuinely need a local scope to compute a value, the TypeScript pattern is the immediately-invoked arrow function:

const url = ((u: URL) => `${u.protocol}//${u.host}${u.pathname}`)(parsed);
Enter fullscreen mode Exit fullscreen mode

That shape is rare in real codebases because most of the time the cleaner write is a const-then-use:

const url = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
Enter fullscreen mode Exit fullscreen mode

If you want pipeline-style scoping for a single value, the TC39 pipe operator proposal is the long-promised solution but has been at Stage 2 for years. Until it ships, libraries like Effect's pipe, fp-ts's pipe, or RxJS's pipe give you the same shape:

import { pipe } from "effect";

const result = pipe(
  parsedUrl,
  (u) => u.pathname.split("/"),
  (parts) => parts.filter(Boolean),
  (parts) => parts.join(" / "),
);
Enter fullscreen mode Exit fullscreen mode

That's the let-of-let-of-let chain you'd write in Kotlin, expressed as a function. Most TypeScript codebases reach for this only when the data flow really is a pipeline. Otherwise: const, const, const.

Why TypeScript got away without them, and where the JVM mind still earns its keep

Kotlin's scope functions exist because the JVM language Kotlin replaced (Java) is verbose about temp variables, has no first-class object literals, and has no lambda-with-receiver syntax. apply and with give you back the configuration ergonomics that Java costs you. They are Java compensation.

TypeScript inherits from JavaScript, which already had what Java was missing:

  • Object literals are the construction primitive. There's no new User() followed by setters because there doesn't have to be.
  • Structural typing means a literal that fits a type is that type. No constructor ceremony.
  • Spread and destructuring cover the merge / pick / restructure cases that Kotlin's apply and with covered for class instances.
  • Arrow functions make a temp scope cheap when you actually need one.

The result is that the four scope functions don't have a single missing-feature replacement. They have four different patterns, each of which TypeScript developers reach for instinctively in their own context. The cultural shift is recognising that you usually want the boring named-temp-variable form, and that this is the idiom rather than a regression.

Three places where the Kotlin habit of "scope this, peek that, configure here" still pays off:

  1. A small tap helper in your utils. Three lines, named tap (or peek, or inspect). Use it sparingly — when it shows up four times in a file, it's earning its keep; when it shows up once, the temp variable was clearer. The Effect / RxJS communities already lean this way; the helper is the standalone version of their operator.

  2. A pipe helper for genuine data flow. When you're transforming A -> B -> C -> D step by step, importing pipe from Effect, fp-ts, Remeda, or writing a 5-line type-safe pipe of your own gives you the linearised reading order without the Promise machinery. This is the closest TypeScript gets to with.

  3. Builders, but only for real construction. SQL builders, query builders, rich-text editors, test fixtures, anything where you assemble across several conditional steps. The fluent .method().method() pattern returning this is a clean TypeScript idiom for those. Don't use it for User.

That's it. Translate the four scope functions as a small tap helper if you reach for it, a pipe import when the data flow earns it, builders for real construction, and a lot of object literals. Once you stop translating User().apply { ... } and start writing { name, age } directly, the rest of the move is small — and the next time you reach for apply, you'll catch yourself before the keystroke.

If this was useful

The TypeScript Library is a 5-book collection that maps cleanly to where you start:

  • TypeScript EssentialsAmazon — entry point. Types, narrowing, modules, async, daily-driver tooling.
  • The TypeScript Type SystemAmazon — deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
  • Kotlin and Java to TypeScriptAmazon — bridge for JVM developers. Variance, null safety, sealed→unions, coroutines→async/await.
  • PHP to TypeScriptAmazon — bridge for PHP 8+ developers. Sync→async paradigm, generics, discriminated unions.
  • TypeScript in ProductionAmazon — production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. Books 3 and 4 substitute for 1 and 2 if you're coming from JVM or PHP — this post is a sample of what book 3 covers. Book 5 is for anyone shipping TypeScript at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)