- Book: Kotlin and Java to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
}
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();
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 };
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)"
}
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,
};
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);
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,
});
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();
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") }
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;
}
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");
});
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}`);
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"
}
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" };
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"
}
That's destructuring in TypeScript:
const summary = (() => {
const { name, age, email } = user;
return `${name} (${age}) — ${email}`;
})();
Or, much more often, inline access:
const summary = `${user.name} (${user.age}) — ${user.email}`;
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
TypeScript covers most of this with optional chaining:
const nameLength = userOrNull?.name.length ?? 0;
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);
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}`;
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(" / "),
);
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
applyandwithcovered 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:
A small
taphelper in your utils. Three lines, namedtap(orpeek, orinspect). 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.A
pipehelper for genuine data flow. When you're transformingA -> B -> C -> Dstep by step, importingpipefrom Effect, fp-ts, Remeda, or writing a 5-line type-safepipeof your own gives you the linearised reading order without the Promise machinery. This is the closest TypeScript gets towith.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 returningthisis a clean TypeScript idiom for those. Don't use it forUser.
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 Essentials — Amazon — entry point. Types, narrowing, modules, async, daily-driver tooling.
-
The TypeScript Type System — Amazon — deep dive. Generics, mapped/conditional types,
infer, template literals, branded types. - Kotlin and Java to TypeScript — Amazon — bridge for JVM developers. Variance, null safety, sealed→unions, coroutines→async/await.
- PHP to TypeScript — Amazon — bridge for PHP 8+ developers. Sync→async paradigm, generics, discriminated unions.
- TypeScript in Production — Amazon — 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.

Top comments (0)