Skip to content
Commitwise
Menu
Programming

Functions, closures and lambdas

Write functions with clear parameters and return values, pass behavior around as closures and lambdas, and understand what a closure captures and how long it keeps it alive, in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A button needs to know what to do when it’s tapped. A list needs to know how to sort. A network call needs to know what to do when the answer arrives. In all three, you hand over a piece of behavior, not a value: a closure or a lambda. Modern Swift, Kotlin, Java and TypeScript code is full of them, and the bugs they cause (callbacks that never update anything, screens that never get freed, this that’s suddenly undefined) all come from one question: what does the function capture?

New to some of these? Open what you need, or skip ahead.

Basics:What are variables and types?

A variable is a named box that holds a value: let age = 30 (Swift), val age = 30 (Kotlin), int age = 30; (Java), const age = 30 (TypeScript). The type says what kind of value fits in the box: Int / int for whole numbers, Double for decimals, String for text, Bool / Boolean for true or false.

  • let (Swift), val (Kotlin) and final (Java) mean the variable can’t be pointed at another value later. var means it can.
  • Swift, Kotlin and TypeScript can often infer the type from the value, so you don’t have to write it. Java needs it written (or var for local variables). TypeScript’s const is like let / val, and its let is like var.
Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often skips classes for data and uses plain objects described by an interface.
Part Not started

Functions: input, output, a name

A function is like a coffee machine: you put something in (water, beans, the size you want), press the button, and something comes out (a coffee). The machine has a name (“espresso”), it expects certain inputs, and it always gives back the same kind of result.

In code, the inputs are parameters, the output is the return value, and you can use the function as often as you like, from anywhere, without repeating its code.

The differences between the four languages are mostly at the call site: Swift’s argument labels are part of the function’s name; Kotlin and TypeScript (via object parameters) get named and default arguments; Java has neither and relies on overloads, builders or parameter objects. Semantically, all four pass arguments by value, where the value of an object is a reference; Swift adds inout for explicit write-back.

func price(base: Double, quantity: Int = 1, discount: Double = 0) -> Double {
    base * Double(quantity) * (1 - discount)
}

let a = price(base: 3.5)                              // 3.5
let b = price(base: 3.5, quantity: 4, discount: 0.1)  // 12.6
fun price(base: Double, quantity: Int = 1, discount: Double = 0.0): Double =
    base * quantity * (1 - discount)

val a = price(3.5)                                    // 3.5
val b = price(base = 3.5, quantity = 4, discount = 0.1)  // 12.6
double price(double base, int quantity, double discount) {
    return base * quantity * (1 - discount);
}

double price(double base) {                           // Java has no defaults: overload instead
    return price(base, 1, 0);
}

double a = price(3.5);                                // 3.5
double b = price(3.5, 4, 0.1);                        // 12.6
function price(base: number, quantity = 1, discount = 0): number {
  return base * quantity * (1 - discount);
}

const a = price(3.5);                                 // 3.5
const b = price(3.5, 4, 0.1);                         // 12.6
  • Parameters are listed with their types. Default values (quantity = 1) let callers skip them.
  • The return type comes after -> (Swift), : (Kotlin, TypeScript), or before the name (Java). A function that returns nothing has Void / Unit / void.
  • Named arguments make calls readable: price(base: 3.5, quantity: 4) says what 4 means. Swift requires the names (argument labels); Kotlin lets you choose; Java and TypeScript only have positions, which is why price(3.5, 4, 0.1) is harder to read.

A function gets a copy of the value

Spot the bugThe discount that never applies

Tap the line with the bug.

func applyDiscount(_ price: Double) {
    var price = price
    price = price * 0.9
}

var total = 100.0
applyDiscount(total)
print(total)
fun applyDiscount(price: Double) {
    var discounted = price
    discounted = discounted * 0.9
}

var total = 100.0
applyDiscount(total)
println(total)
void applyDiscount(double price) {
    price = price * 0.9;
}

double total = 100.0;
applyDiscount(total);
System.out.println(total);
function applyDiscount(price: number): void {
  price = price * 0.9;
}

let total = 100.0;
applyDiscount(total);
console.log(total);

Functions that return several things

When a function needs to give back more than one value, return a small type with named fields instead of an array or a list:

func minMax(_ numbers: [Int]) -> (min: Int, max: Int)? {
    guard let first = numbers.first else { return nil }
    return numbers.reduce((min: first, max: first)) { (min($0.min, $1), max($0.max, $1)) }
}

if let range = minMax([4, 9, 1]) { print(range.min, range.max) }   // 1 9
data class Range(val min: Int, val max: Int)

fun minMax(numbers: List<Int>): Range? =
    if (numbers.isEmpty()) null else Range(numbers.min(), numbers.max())

minMax(listOf(4, 9, 1))?.let { (min, max) -> println("$min $max") }   // 1 9
record Range(int min, int max) {}

Optional<Range> minMax(List<Integer> numbers) {
    if (numbers.isEmpty()) return Optional.empty();
    return Optional.of(new Range(Collections.min(numbers), Collections.max(numbers)));
}
function minMax(numbers: number[]): { min: number; max: number } | undefined {
  if (numbers.length === 0) return undefined;
  return { min: Math.min(...numbers), max: Math.max(...numbers) };
}

const range = minMax([4, 9, 1]);
if (range) console.log(range.min, range.max);   // 1 9

An empty list has no minimum, so the function says so with an optional result instead of inventing a number. See Null safety.

Designing a function’s signature

  • Long parameter lists of the same type (createUser(String, String, String, boolean)) invite swapped arguments. Swift labels and Kotlin named arguments help at the call site; in Java and TypeScript, introduce a parameter object (a record, an options object) or a builder.
  • Boolean flags usually mean two functions: send(message, true) is sendUrgent(message).
  • Defaults across languages: Kotlin defaults aren’t visible to Java callers without @JvmOverloads; Swift defaults are evaluated in the callee’s context on each call; TypeScript defaults apply to undefined, not to null.
  • inout in Swift is copy-in copy-out, not a reference: the caller’s variable is written back when the function returns, and overlapping access (passing the same variable twice) is a compile-time or runtime exclusivity error.
Deep diveWhy Swift's argument labels are part of the nameInterview favorite

In Swift, move(from:to:) and move(to:) are different functions, and the labels are written at every call. That comes from Objective-C, where moveFrom:to: was literally the selector. The API design guidelines build on it: labels should make a call read like an English phrase (insert(x, at: 0), remove(at: 3)), and the first label is omitted when the base name already says what the argument is (append(x)). It’s why Swift APIs rarely need named-argument conventions or parameter objects that other languages use for readability.

  • All four pass by value; for objects the value is a reference. Swift inout is copy-in copy-out with exclusivity checks.
  • Readability at the call site: Swift labels (part of the name), Kotlin named/default args (@JvmOverloads for Java callers), TypeScript options objects, Java overloads/parameter objects/builders.
  • Pure functions (input → output, no side effects) are the easiest to test; return new values instead of mutating parameters.
func send(_ message: String, to user: User, urgent: Bool = false) { /* … */ }
send("Hi", to: ana, urgent: true)
fun send(message: String, to: User, urgent: Boolean = false) { /* … */ }
send("Hi", to = ana, urgent = true)
record SendOptions(User to, boolean urgent) {}
void send(String message, SendOptions options) { /* … */ }
send("Hi", new SendOptions(ana, true));
function send(message: string, { to, urgent = false }: { to: User; urgent?: boolean }) { /* … */ }
send("Hi", { to: ana, urgent: true });

Check this part

  1. A function changes its number parameter. What happens to the caller's variable?

  2. What does a default parameter value do?

  3. How does Swift's inout work?Can Java callers use a Kotlin function's default arguments?Java has no named or default arguments. What keeps long signatures safe?When does a TypeScript default parameter apply?

A function takes parameters and returns a result. Parameters are copies of the caller's values (or of references), so to change something for the caller, return the new value. Named and default arguments make calls readable.

Part Not started

Functions as values

Imagine leaving instructions for a babysitter: “if the baby wakes up, warm the milk”. You’re not doing it now; you hand over a piece of behavior for someone else to run later, when the time comes.

A closure (Swift) or lambda (Kotlin, Java, TypeScript) is that: a function without a name, written right where you need it, that you can pass to another function, store in a variable, and run later.

Functions are first-class in Swift, Kotlin and TypeScript (function types like (Int) -> Bool). Java models them as single-method interfaces (Function<T, R>, Predicate<T>, Runnable) that lambdas implement. The semantic difference that matters is capture: Swift, Kotlin and TypeScript capture variables by reference (the closure sees later changes and can mutate them); Java captures only effectively final locals, by value, so mutable state must live in an object.

let prices = [12, 4, 30, 8]

let cheapFirst = prices.sorted { $0 < $1 }             // a closure decides the order
let expensive = prices.filter { price in price > 10 }  // and which items to keep

let isEven: (Int) -> Bool = { $0 % 2 == 0 }            // a closure stored in a constant
print(isEven(4))                                       // true
val prices = listOf(12, 4, 30, 8)

val cheapFirst = prices.sortedBy { it }                // a lambda decides the order
val expensive = prices.filter { price -> price > 10 }  // and which items to keep

val isEven: (Int) -> Boolean = { it % 2 == 0 }         // a lambda stored in a constant
println(isEven(4))                                     // true
List<Integer> prices = List.of(12, 4, 30, 8);

List<Integer> cheapFirst = prices.stream().sorted((a, b) -> Integer.compare(a, b)).toList();
List<Integer> expensive = prices.stream().filter(price -> price > 10).toList();

Predicate<Integer> isEven = n -> n % 2 == 0;           // a lambda stored in a variable
System.out.println(isEven.test(4));                    // true
const prices = [12, 4, 30, 8];

const cheapFirst = [...prices].sort((a, b) => a - b);  // a function decides the order
const expensive = prices.filter((price) => price > 10); // and which items to keep

const isEven = (n: number): boolean => n % 2 === 0;    // a function stored in a constant
console.log(isEven(4));                                // true

The short forms are worth recognizing: Swift’s $0 is the first parameter, Kotlin’s it is the only parameter, and when a closure is the last argument, Swift and Kotlin let you write it after the parentheses (a trailing closure): prices.filter { $0 > 10 }.

Closures remember where they were made

A closure can use variables from the code around it, and keeps them alive even after that code has finished:

func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1               // uses and changes count from makeCounter
        return count
    }
}

let next = makeCounter()
print(next(), next(), next())    // 1 2 3
fun makeCounter(): () -> Int {
    var count = 0
    return {
        count += 1               // uses and changes count from makeCounter
        count
    }
}

val next = makeCounter()
println("${next()} ${next()} ${next()}")   // 1 2 3
Supplier<Integer> makeCounter() {
    AtomicInteger count = new AtomicInteger();   // a local int couldn't be changed by the lambda
    return () -> count.incrementAndGet();
}

Supplier<Integer> next = makeCounter();
System.out.println(next.get() + " " + next.get() + " " + next.get());   // 1 2 3
function makeCounter(): () => number {
  let count = 0;
  return () => {
    count += 1;                  // uses and changes count from makeCounter
    return count;
  };
}

const next = makeCounter();
console.log(next(), next(), next());   // 1 2 3

makeCounter has finished running after the first line, but count lives on, because the closure it returned still uses it. That’s capturing. Each call to makeCounter() creates a new count, so two counters don’t share their numbers.

Java note

A Java lambda can only use local variables that never change after they’re set (“effectively final”). To keep changing state, the lambda uses an object that holds it, like AtomicInteger here, or a field.

Predict the output

What does this print?

let a = makeCounter()
let b = makeCounter()
print(a(), a(), b())
val a = makeCounter()
val b = makeCounter()
println("${a()} ${a()} ${b()}")
Supplier<Integer> a = makeCounter();
Supplier<Integer> b = makeCounter();
System.out.println(a.get() + " " + a.get() + " " + b.get());
const a = makeCounter();
const b = makeCounter();
console.log(a(), a(), b());

Practice: write a function that takes a function

Exerciseeasy

Retry an action

Write retry(times, action): it calls action (a function with no parameters that returns true on success) up to times times, stops as soon as it succeeds, and returns whether it succeeded.

retry(3, { flakyUpload() })   // calls flakyUpload at most 3 times
›

The parameter type is a function: Swift () -> Bool, Kotlin () -> Boolean, Java BooleanSupplier, TypeScript () => boolean.

›

A loop from 1 to times; inside it, if action() { return true }. After the loop, every attempt failed.

SolutionTry it yourself first
func retry(times: Int, _ action: () -> Bool) -> Bool {
    for _ in 0..<times where action() {
        return true
    }
    return false
}

let ok = retry(times: 3) { flakyUpload() }
fun retry(times: Int, action: () -> Boolean): Boolean {
    repeat(times) {
        if (action()) return true
    }
    return false
}

val ok = retry(3) { flakyUpload() }
boolean retry(int times, BooleanSupplier action) {
    for (int i = 0; i < times; i++) {
        if (action.getAsBoolean()) return true;
    }
    return false;
}

boolean ok = retry(3, () -> flakyUpload());
function retry(times: number, action: () => boolean): boolean {
  for (let i = 0; i < times; i++) {
    if (action()) return true;
  }
  return false;
}

const ok = retry(3, () => flakyUpload());

retry doesn’t know or care what the action does; it only decides how often to run it. Separating “what” from “how often” is the whole point of passing functions around. A real version would wait between attempts and work with errors and async code, which is where error handling and concurrency come in.

Capture semantics

Predict the output

What does this print?

var x = 1
let printX = { print(x) }
x = 2
printX()
var x = 1
val printX = { println(x) }
x = 2
printX()
int x = 1;
Runnable printX = () -> System.out.println(x);
x = 2;
printX.run();
let x = 1;
const printX = () => console.log(x);
x = 2;
printX();

Higher-order functions in production

Exercisemedium

Memoize a function

Write a generic memoize(f) that returns a function with the same behavior, caching results by argument so repeated calls with the same input don’t recompute. Then review it: when would you not ship it as written?

›

The cache lives inside memoize and is captured by the returned closure, exactly like count in makeCounter.

SolutionTry it yourself first
func memoize<In: Hashable, Out>(_ f: @escaping (In) -> Out) -> (In) -> Out {
    var cache: [In: Out] = [:]
    return { input in
        if let hit = cache[input] { return hit }
        let result = f(input)
        cache[input] = result
        return result
    }
}
fun <In, Out> memoize(f: (In) -> Out): (In) -> Out {
    val cache = HashMap<In, Out>()
    return { input -> cache.getOrPut(input) { f(input) } }
}
static <I, O> Function<I, O> memoize(Function<I, O> f) {
    Map<I, O> cache = new ConcurrentHashMap<>();
    return input -> cache.computeIfAbsent(input, f);
}
function memoize<I, O>(f: (input: I) => O): (input: I) => O {
  const cache = new Map<I, O>();
  return (input) => {
    if (cache.has(input)) return cache.get(input)!;
    const result = f(input);
    cache.set(input, result);
    return result;
  };
}

Review points:

  • Unbounded growth: the cache never evicts. Fine for a small key space; a memory leak for user-controlled keys. Add a size limit (LRU) or a TTL.
  • Thread safety: the Swift, Kotlin and TypeScript versions mutate a plain map from the closure; calling them from several threads (or Swift tasks) is a data race. Swift 6 strict concurrency flags the @escaping closure capturing mutable state if it must be @Sendable.
  • Java’s computeIfAbsent and recursion: memoizing a recursive function (Fibonacci calling its memoized self) makes ConcurrentHashMap.computeIfAbsent throw IllegalStateException: Recursive update. Recursive memoization needs a get-then-put pattern or explicit DP.
  • Keys: TypeScript Map compares objects by identity, so memoize(f)({ id: 1 }) never hits twice; key by a primitive or serialized value. Kotlin’s getOrPut recomputes when the cached value is null.
  • Purity: only memoize functions whose result depends solely on the input; memoizing “current price” or anything time-based caches stale answers forever.
  • Swift, Kotlin and TypeScript closures capture variables (by reference): later changes are visible, and the closure can mutate them. Swift capture lists ([x]) snapshot values.
  • Java lambdas capture only effectively final locals, by value; mutable state lives in an object (AtomicInteger, a field).
  • Java function types are functional interfaces (Function, Supplier, Predicate, BooleanSupplier); Kotlin and Swift have native function types; trailing-lambda syntax in Swift and Kotlin.
  • Capturing creates hidden state: memoizers and callbacks need bounds, thread safety and a clear lifetime.

Check this part

  1. What is a closure (or lambda)?

  2. makeCounter() is called twice, creating counters a and b. After a(), a(), what does b() return?

  3. var x = 1; let f = { print(x) }; x = 2; f() prints…var x = 1; val f = { println(x) }; x = 2; f() prints…int x = 1; Runnable f = () -> System.out.println(x); x = 2; is…let x = 1; const f = () => console.log(x); x = 2; f() prints…

  4. What is the main production risk of a naive memoize?

Functions are values: store them, pass them, return them. A closure (lambda) can use variables from where it was created, and it keeps them alive. Swift, Kotlin and TypeScript capture the variable itself; Java only captures values that never change.

Part Not started

Callbacks and what they hold on to

Callbacks are functions you give to something else to call later: a button’s tap handler, a download’s completion, a listener for data changes. Because a callback can use anything around it, it can also keep alive anything around it, including whole screens.

func loadAvatar(for user: User, completion: @escaping (UIImage?) -> Void) {
    URLSession.shared.dataTask(with: user.avatarURL) { data, _, _ in
        completion(data.flatMap(UIImage.init))
    }.resume()
}

loadAvatar(for: user) { [weak self] image in   // don't keep the screen alive just for this
    self?.avatarView.image = image
}
fun loadAvatar(user: User, onLoaded: (Bitmap?) -> Unit) {
    // … calls onLoaded later, on another thread
}

loadAvatar(user) { bitmap ->
    avatarView.setImageBitmap(bitmap)        // uses the screen's view: keeps the screen alive
}
void loadAvatar(User user, Consumer<Bitmap> onLoaded) {
    // … calls onLoaded later, on another thread
}

loadAvatar(user, bitmap -> avatarView.setImageBitmap(bitmap));   // keeps the screen alive
function loadAvatar(user: User, onLoaded: (image: Blob | undefined) => void): void {
  fetch(user.avatarUrl).then((r) => r.blob()).then(onLoaded, () => onLoaded(undefined));
}

loadAvatar(user, (image) => {
  if (image) avatarImg.src = URL.createObjectURL(image);   // keeps avatarImg alive until called
});

The callback mentions avatarView (or self), so as long as the download holds on to the callback, it also holds on to the screen. For a quick download, that’s fine: the screen is freed a moment later. For a callback that’s kept for a long time, like a listener registered with something that lives as long as the app, the screen is never freed. In Swift, [weak self] tells the closure “use the screen if it still exists, but don’t keep it alive”. The full story is in Memory: ARC vs garbage collection.

A closure’s captures are strong references (Swift) or GC roots-by-reachability (JVM, JS) for as long as the closure is reachable. The lifetime of a callback is therefore the lifetime of everything it captures. Rules that hold across platforms: register long-lived callbacks with an explicit lifetime (a cancellation token, a lifecycle owner, a disposable), capture the minimum (a weak reference, a value, not self), and treat method references as captures of their receiver.

Practice: a listener that’s removed

  1. Register a callback when the screen appears: cart.addListener(callback).
  2. Keep what you need to unregister: the returned token, or the callback itself.
  3. Unregister when the screen goes away: cart.removeListener(callback) in onDisappear, onDestroy, ngOnDestroy or a cleanup function.
  4. Check with a memory tool (Xcode’s Memory Graph, Android Studio’s profiler or LeakCanary, Chrome’s heap snapshot) that closing the screen frees it.

Modern APIs often do steps 2 and 3 for you: SwiftUI’s .task and .onReceive, Kotlin’s repeatOnLifecycle with Flow, and AbortController in TypeScript tie the callback to a lifetime automatically.

Production puzzles

Spot the bugA method passed as a callback

Tap the line with the bug.

final class ProfileScreen {
    let saveButton = Button()

    init() {
        saveButton.onTap = handleSave
    }

    func handleSave() { print("saved") }
}
class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        CartRepository.addListener(::render)
    }

    private fun render(cart: Cart) { /* update views */ }
}
public class ProfileActivity extends AppCompatActivity {
    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);
        CartRepository.INSTANCE.addListener(this::render);
    }

    private void render(Cart cart) { /* update views */ }
}
class ProfilePage {
  private saves = 0;

  constructor(private readonly button: HTMLButtonElement) {
    button.addEventListener("click", this.handleSave);
  }

  handleSave() {
    this.saves++;
  }
}

Deep diveEscaping and non-escaping closuresInterview favorite

Swift distinguishes closures that only run during the call (non-escaping, the default: map, filter, sorted) from ones that are stored and run later (@escaping: completion handlers, stored callbacks). Non-escaping closures can’t outlive the call, so they can’t create retain cycles, don’t need explicit self., and can be optimized to avoid heap allocation. Kotlin’s inline functions play a similar role: the lambda is inlined at the call site, can use non-local return, and costs no allocation, while noinline/crossinline mark lambdas that are stored or run in another context.

  • A callback’s lifetime is the lifetime of everything it captures. Long-lived registrations need an explicit lifetime: cancellation token, lifecycle owner, disposable, AbortController.
  • Method references capture their receiver: Swift obj.method is a strong capture (cycle risk); Kotlin ::render / Java this::render keep the Activity reachable from singletons. TypeScript this.method loses this; use an arrow function or bind.
  • Swift @escaping vs non-escaping; Kotlin inline/crossinline/noinline.
cancellable = cart.$items.sink { [weak self] items in self?.render(items) }
repeatOnLifecycle(Lifecycle.State.STARTED) { cart.items.collect { render(it) } }
cartLiveData.observe(this, this::render);   // removed automatically when the owner is destroyed
button.addEventListener("click", () => this.save(), { signal: controller.signal });

Check this part

  1. A callback uses a screen's views. How long does the screen stay in memory?

  2. A screen adds a listener to an object that lives as long as the app. What should it do when it closes?

  3. Why is button.onTap = handleSave inside a view controller a problem?Why is CartRepository.addListener(::render) in an Activity a problem?Why is CartRepository.INSTANCE.addListener(this::render) in an Activity a problem?Why does button.addEventListener("click", this.handleSave) break?

A callback that uses an object keeps that object alive as long as the callback exists. Register callbacks with a lifetime: weak capture in Swift, lifecycle-aware collection on Android, unregister when done. In TypeScript, passing a method loses its this.

Key takeaways

  • A function takes parameters and returns a result; to change something for the caller, return the new value.
  • Named and default arguments make calls easy to read.
  • Closures and lambdas are functions you pass around; they can use, and keep alive, variables from where they were created.
  • A long-lived callback keeps everything it uses alive: remove listeners and use [weak self] where needed.
  • All four pass by value (references for objects); design signatures for call-site clarity with labels, named args or parameter objects.
  • Swift, Kotlin and TypeScript capture variables; Java captures effectively final values.
  • Hidden captured state (memoizers, counters) needs bounds and thread safety.
  • Method references capture their receiver (or lose it in TypeScript); give every long-lived callback an explicit lifetime.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.