- Book: Kotlin and Java to TypeScript — A Bridge for JVM Developers
- 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 engineer ports their first non-trivial service to Node. The handler launches three calls in parallel, the request times out, and the engineer expects the in-flight work to stop. It does not. Two of the three downstream calls finish ten seconds later, write to the database, and emit a metric for a request whose response was already a 504.
In Kotlin, that bug does not exist. Every coroutineScope and every launch belongs to a parent. The runtime cancels every child recursively and atomically when the parent is cancelled. It is not a library feature. It is the central design decision of kotlinx.coroutines. The Kotlin docs call it structured concurrency.
JavaScript has none of this. A Promise does not have cancel(). Once you call a function that returns one, the work inside is unaccountable to its caller. Nothing says "the parent died, you should stop." That hole has been filled three different ways in the TypeScript world, and they are not interchangeable.
What follows is the JVM engineer's map: three patterns, what they cost, and which one matches the Kotlin habit you actually need.
What you're losing when you leave Kotlin
A Kotlin function that does three calls and waits for all of them looks like this:
suspend fun loadProfile(id: String): Profile = coroutineScope {
val user = async { userClient.fetch(id) }
val perms = async { permsClient.fetch(id) }
val flags = async { flagsClient.fetch(id) }
Profile(user.await(), perms.await(), flags.await())
}
Three things are free here:
- If the caller is cancelled, all three
asyncblocks are cancelled. The HTTP clients receive the cancellation throughcoroutineContextand stop reading from their sockets. - If
userClient.fetchthrows, the other two are cancelled before the exception propagates. No orphaned work. - The function returns when all three children are done. It cannot return while a child is still running. That is the structured-concurrency contract.
The TypeScript equivalent that looks similar is not similar:
async function loadProfile(id: string): Promise<Profile> {
const [user, perms, flags] = await Promise.all([
userClient.fetch(id),
permsClient.fetch(id),
flagsClient.fetch(id),
]);
return { user, perms, flags };
}
If the caller goes away, the three fetch promises keep going. Promise.all rejecting on the first error does not cancel the other two; the other two finish on their own time and resolve into a request that no longer has a caller. The function does not know it has lost an audience.
That is the gap. Three patterns close it. They are not equivalent.
Pattern 1: AbortController, passed down by hand
AbortController is the platform answer. It ships in Node 16+, every browser, Bun, and Deno. The shape is one controller, many signals, all derived from the same source.
type Profile = { user: User; perms: Perms; flags: Flags };
async function loadProfile(
id: string,
signal: AbortSignal,
): Promise<Profile> {
const [user, perms, flags] = await Promise.all([
userClient.fetch(id, { signal }),
permsClient.fetch(id, { signal }),
flagsClient.fetch(id, { signal }),
]);
return { user, perms, flags };
}
// caller
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 2_000);
try {
const profile = await loadProfile("u-42", ac.signal);
return profile;
} finally {
clearTimeout(t);
}
What this gives you, if every layer cooperates:
-
fetchis built to honorAbortSignal. The socket closes. The promise rejects with anAbortError. - The 2s deadline cascades. One controller, one abort, all three
fetchcalls die together. - Your own code can subscribe:
signal.addEventListener('abort', () => ...)to clean up timers, file handles, listeners.
What it does not give you, and where the JVM habit hurts:
- Every async function in the chain has to take a
signalparameter and pass it on. Forget one and the cancellation stops there. There is nocoroutineContextyou can read implicitly. - Cancellation does not propagate to a
setTimeoutyou forgot to wire up, a CPU-bound loop, or a third-party library that never accepted a signal. - It is a contract, honored only when every link cooperates.
For services where the work is mostly fetch calls and database queries to clients that already accept AbortSignal (at the time of writing: undici, node-fetch, pg, mongodb, the @aws-sdk/* v3 clients, OpenAI's SDK), this pattern is enough. Most typical Node handlers fall in that bucket.
The cost is the boilerplate. Every internal helper grows a parameter. Every test grows a controller. The codebase visibly carries the cost of cancellation everywhere it goes. That is honest, but verbose.
AbortSignal.timeout(ms) and AbortSignal.any([...]) (Node 20+, modern browsers) help. Combine a request signal with a 2s deadline and you avoid managing the timer by hand:
const reqSignal = req.signal; // from the framework
const signal = AbortSignal.any([
reqSignal,
AbortSignal.timeout(2_000),
]);
const profile = await loadProfile("u-42", signal);
That is closer to the Kotlin feel. One signal, derived from two parents, cancels when either does.
Pattern 2: cancellation token with explicit checkpoints
AbortController is enough for I/O. It is not enough for CPU work, because a tight JS loop never yields to check the signal. You need the equivalent of Kotlin's ensureActive(): a checkpoint your code calls between chunks.
The pattern is a thin token wrapping the signal, with a method that throws if cancelled:
class CancellationToken {
constructor(public readonly signal: AbortSignal) {}
static fromSignal(signal: AbortSignal): CancellationToken {
return new CancellationToken(signal);
}
throwIfCancelled(): void {
if (this.signal.aborted) {
throw this.signal.reason ?? new Error("cancelled");
}
}
}
// CPU-bound worker that respects the token
function processBatch(
items: Item[],
token: CancellationToken,
): Result[] {
const out: Result[] = [];
for (let i = 0; i < items.length; i++) {
if ((i & 0xff) === 0) token.throwIfCancelled(); // every 256 items
out.push(transform(items[i]));
}
return out;
}
The shape mirrors Kotlin's yield() and ensureActive(). You decide where the checkpoints land. You pay for them with one branch per chunk, not one per item.
The same token works across mixed I/O and CPU work, because it carries the same AbortSignal underneath:
async function pipeline(
ids: string[],
token: CancellationToken,
): Promise<Result[]> {
const raw = await fetchMany(ids, { signal: token.signal });
token.throwIfCancelled(); // cheap re-check between phases
const processed = processBatch(raw, token);
token.throwIfCancelled();
return saveMany(processed, { signal: token.signal });
}
Two things this pattern gets right that bare AbortSignal misses:
- It puts a single, named verb on the cancellation contract.
token.throwIfCancelled()reads as one idea;if (signal.aborted) throw signal.reasonis plumbing. - It centralizes the throw shape. Every cancellation throws the same error type, which makes the catch sites uniform:
if (isAbortError(e)) ... else ....
When does the token earn the wrapper class? When CPU-bound work or long-lived background jobs need many checkpoints, or when the codebase has more than one library that defines its own abort error. The wrapper hides the divergence.
When is it overkill? Pure fetch-and-await services. Stay with AbortSignal.
Pattern 3: Effect.ts Fiber for structured concurrency
Effect is the TypeScript ecosystem's answer to the question "what would a kotlinx.coroutines-shaped runtime look like in JS?" The unit is a Fiber. Every Fiber has a parent. Cancellation propagates down the tree. A timeout on the parent cancels every child mid-flight.
The previous example in Effect:
import { Effect, Duration } from "effect";
const loadProfile = (id: string) =>
Effect.all(
{
user: userClient.fetch(id),
perms: permsClient.fetch(id),
flags: flagsClient.fetch(id),
},
{ concurrency: "unbounded" },
);
// `Effect.all` interrupts siblings on the first failure by default.
const program = loadProfile("u-42").pipe(
Effect.timeout(Duration.seconds(2)),
);
Effect.runPromise(program);
Effect.timeout does what withTimeout does in Kotlin. The three children are real Fibers. When the timeout fires, the runtime walks the Fiber tree and interrupts every descendant. The HTTP clients you wired through Effect (or via Effect.tryPromise with a cleanup) see the interruption and stop. The function as a whole returns a typed TimeoutException in its error channel. You also get the Kotlin-style "fail fast on the first error" for free, because that is how Effect.all already behaves.
This is the closest TypeScript gets to writing Kotlin. The cost is the rest of Effect: the Effect<A, E, R> type, the runtime, the learning curve, and the fact that many libraries you use will need to be wrapped with Effect.tryPromise and an explicit cancel callback before they participate in interruption.
When does it earn the cost? When the Kotlin habit you actually want is the one you can't fake: cascading cancellation through code your team writes, with deadlines composed from multiple parents, retries-as-values, and a typed error channel that survives to the edge of the program. Long-running background pipelines, agent loops with budgets, event-driven systems with structured supervision. That's where Effect's Fiber model earns the ramp.
When is it overkill? Most CRUD services. The cancellation needs of a typical handler do not justify the language-inside-a-language. Use AbortController, accept the boilerplate, ship.
A decision rule that holds up
does the work include CPU loops
or library code that ignores AbortSignal?
|
no | yes
+-------------------+-------------------+
| |
AbortController only do you also need:
(Pattern 1) - cascading cancel through
- fetch + DB clients a deep call tree
- Plain Promise.all - typed error channel
- AbortSignal.any - retry/schedule as values
- AbortSignal.timeout |
no | yes
+------------+-------------+
| |
CancellationToken Effect.ts Fiber
(Pattern 2) (Pattern 3)
- explicit checkpoints - structured concurrency
- one named verb - language-grade
- mixed I/O + CPU - high ramp
Three lines summarise it:
- I/O only, mainstream clients:
AbortController. Done. - I/O plus CPU, or you want one named cancel verb: wrap a
CancellationTokenaround the signal. - The Kotlin shape is the actual requirement (cascading cancel, deadlines, typed errors as part of the type): pay for Effect.
What does not translate, and you should stop trying
A few Kotlin habits do not survive the trip. Knowing them up front saves a sprint.
-
runBlockinghas no honest equivalent. JS has one event loop. Blocking it stalls the process. If you find yourself wishing for it, the answer is to keep the functionasyncall the way up. - Cancellation is not free safety in the way it is in Kotlin. A Kotlin coroutine that ignores cancellation is a bug; a JS function that ignores
AbortSignalis the default. You are negotiating a contract, not standing on a runtime guarantee. -
JobandSupervisorJobmap roughly to "a parent that doesn't fail when a child fails." In plain Promise-land you simulate it withPromise.allSettled. In Effect, you getEffect.forkDaemonand supervision policies. There is no platform answer.
JS gives you platform-level cancellation only for I/O. Everything else is a contract you wrote yourself.
Wire your next handler with AbortSignal.any([req.signal, AbortSignal.timeout(2_000)]). That single change closes the gap that kicked this post off: the orphaned fetch that wrote a row ten seconds after the request was already a 504. Most of the cancellation discipline you miss from Kotlin is one signal away.
If this was useful
The Kotlin and Java to TypeScript move is full of patterns where the names match and the semantics drift. Cancellation is the loudest example; null safety, sealed types to discriminated unions, variance, and suspend to async/await are the others. Kotlin and Java to TypeScript — A Bridge for JVM Developers walks through each one, with the same comparison-and-decision structure as this post.
If you want the broader set, the five-book TypeScript Library covers the core type system, foundations, the JVM and PHP bridges, and production tooling. Pick the bridge if you're coming from the JVM; add the type-system book once you're past the syntax; the production volume is for anyone shipping TS at work.
- TypeScript Essentials — From Working Developer to Confident TS — entry point if you're past the JVM bridge
- The TypeScript Type System — From Generics to DSL-Level Types — generics, conditional types, branded types
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — this post is one section of one chapter
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — the other bridge book
- TypeScript in Production — Tooling, Build, and Library Authoring — tsconfig, monorepos, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.



Top comments (0)