Dart Advanced Features

Closures & Currying

45 min Lesson 9 of 16

What Are Closures?

A closure is a function that captures variables from its surrounding lexical scope. Even after the outer function has returned, the closure retains access to those variables. In Dart, every function is a closure — it can “close over” variables defined in its enclosing scope.

Basic Closure Example

void main() {
  String greeting = 'Hello';

  void sayHello(String name) {
    // This inner function "captures" greeting from the outer scope
    print('$greeting, $name!');
  }

  sayHello('Edrees');  // Hello, Edrees!

  greeting = 'Hi';
  sayHello('Edrees');  // Hi, Edrees!  (captures the variable, not the value)
}
Note: Closures capture variables, not values. If the captured variable changes, the closure sees the updated value. This is a critical distinction that affects how closures behave in loops and asynchronous code.

Lexical Scope in Dart

Dart uses lexical scoping, also called static scoping. A function can access variables from every enclosing scope, from its own local variables outward to the top-level scope. The scope is determined by the code’s structure at write time, not at runtime.

Nested Scope Chain

String topLevel = 'I am top-level';

void outerFunction() {
  String outerVar = 'I am outer';

  void middleFunction() {
    String middleVar = 'I am middle';

    void innerFunction() {
      String innerVar = 'I am inner';

      // innerFunction can access ALL variables:
      print(topLevel);   // OK
      print(outerVar);   // OK
      print(middleVar);  // OK
      print(innerVar);   // OK
    }

    innerFunction();
    // print(innerVar);  // ERROR: innerVar is not in scope here
  }

  middleFunction();
}

void main() {
  outerFunction();
}

Returning Functions (Closure Factories)

One of the most powerful patterns with closures is returning a function from another function. The returned function remembers the variables from its creation context, even after the outer function has finished executing.

Counter Factory

Function makeCounter({int start = 0, int step = 1}) {
  int count = start;

  return () {
    count += step;
    return count;
  };
}

void main() {
  final counter1 = makeCounter();
  print(counter1());  // 1
  print(counter1());  // 2
  print(counter1());  // 3

  final counter2 = makeCounter(start: 10, step: 5);
  print(counter2());  // 15
  print(counter2());  // 20

  // counter1 and counter2 have independent state!
  print(counter1());  // 4
}
Tip: Each call to makeCounter() creates a brand new count variable. The returned closure captures that specific variable, so multiple counters are completely independent of each other. This is a clean way to create stateful behavior without classes.

Closures Over Loop Variables

A common pitfall in many languages is capturing loop variables inside closures. In Dart, using for-in or a standard for loop creates a new variable for each iteration, which avoids the classic bug.

Closures in Loops — Correct Behavior

void main() {
  final functions = <Function>[];

  // Each iteration creates a new 'i', so each closure captures its own copy
  for (var i = 0; i < 5; i++) {
    functions.add(() => print(i));
  }

  for (var fn in functions) {
    fn();  // Prints 0, 1, 2, 3, 4  (each closure has its own i)
  }
}

Potential Pitfall with Shared Variables

void main() {
  final functions = <Function>[];
  var shared = 0;

  for (var i = 0; i < 5; i++) {
    functions.add(() => print(shared));
    shared++;
  }

  // All closures share the SAME 'shared' variable
  for (var fn in functions) {
    fn();  // Prints 5, 5, 5, 5, 5 (all see final value of shared)
  }
}
Warning: When closures capture a variable declared outside the loop, they all share the same variable. If that variable changes after the closures are created, all closures will see the final value. Always ensure your captured variables have the scope you intend.

Partial Application

Partial application is a technique where you fix some arguments of a function, producing a new function that takes the remaining arguments. This is extremely useful for creating specialized versions of general functions.

Partial Application in Practice

// A general function that takes three arguments
double calculatePrice(double basePrice, double taxRate, double discount) {
  return basePrice * (1 + taxRate) - discount;
}

// Partial application: fix the tax rate for a specific region
Function applyTax(double taxRate) {
  return (double basePrice, double discount) {
    return calculatePrice(basePrice, taxRate, discount);
  };
}

void main() {
  // Create region-specific pricing functions
  final usPricing = applyTax(0.08);    // 8% tax
  final euPricing = applyTax(0.20);    // 20% VAT

  print(usPricing(100.0, 10.0));  // 100 * 1.08 - 10 = 98.0
  print(euPricing(100.0, 10.0));  // 100 * 1.20 - 10 = 110.0
}

Currying

Currying transforms a function that takes multiple arguments into a chain of functions, each taking a single argument. While partial application fixes some arguments at once, currying strictly converts to single-argument functions.

Currying a Function

// Normal multi-argument function
int add(int a, int b) => a + b;

// Curried version: returns a chain of single-argument functions
int Function(int) Function(int) curriedAdd = (int a) => (int b) => a + b;

void main() {
  // Use the curried function step by step
  final addFive = curriedAdd(5);
  print(addFive(3));   // 8
  print(addFive(10));  // 15

  // Or call it all at once
  print(curriedAdd(2)(3));  // 5
}

Generic Curry Helper

// A generic curry function for two-argument functions
C Function(B) Function(A) curry2<A, B, C>(C Function(A, B) fn) {
  return (A a) => (B b) => fn(a, b);
}

// A generic curry function for three-argument functions
D Function(C) Function(B) Function(A) curry3<A, B, C, D>(
    D Function(A, B, C) fn) {
  return (A a) => (B b) => (C c) => fn(a, b, c);
}

String formatGreeting(String greeting, String title, String name) {
  return '$greeting, $title $name!';
}

void main() {
  final curriedGreet = curry3(formatGreeting);

  final helloTo = curriedGreet('Hello');
  final helloMr = helloTo('Mr.');
  final helloDr = helloTo('Dr.');

  print(helloMr('Smith'));   // Hello, Mr. Smith!
  print(helloDr('Jones'));   // Hello, Dr. Jones!
}

Practical Example: Memoization

Memoization uses closures to cache the results of expensive function calls. When called again with the same arguments, the cached result is returned instead of recalculating.

Memoization with Closures

/// Creates a memoized version of a single-argument function.
R Function(T) memoize<T, R>(R Function(T) fn) {
  final cache = <T, R>{};

  return (T arg) {
    if (cache.containsKey(arg)) {
      print('  Cache hit for $arg');
      return cache[arg] as R;
    }
    print('  Computing for $arg');
    final result = fn(arg);
    cache[arg] = result;
    return result;
  };
}

int expensiveSquare(int n) {
  // Simulate expensive computation
  return n * n;
}

void main() {
  final memoSquare = memoize(expensiveSquare);

  print(memoSquare(5));   // Computing for 5 → 25
  print(memoSquare(5));   // Cache hit for 5 → 25
  print(memoSquare(10));  // Computing for 10 → 100
  print(memoSquare(10));  // Cache hit for 10 → 100
}

Practical Example: Event Handler Factories

Closures are ideal for creating specialized event handlers. Instead of passing configuration data around, you bake it into the closure at creation time.

Event Handler Factory

typedef EventHandler = void Function(String eventData);

EventHandler createLogger(String component, {bool verbose = false}) {
  int eventCount = 0;

  return (String eventData) {
    eventCount++;
    if (verbose) {
      print('[$component] Event #$eventCount: $eventData '
            '(timestamp: ${DateTime.now()})');
    } else {
      print('[$component] $eventData');
    }
  };
}

void main() {
  final authLogger = createLogger('Auth', verbose: true);
  final dbLogger = createLogger('Database');

  authLogger('User logged in');
  // [Auth] Event #1: User logged in (timestamp: ...)
  authLogger('Token refreshed');
  // [Auth] Event #2: Token refreshed (timestamp: ...)

  dbLogger('Query executed');
  // [Database] Query executed
}

Practical Example: Configuration Builders

Closures enable a builder pattern where each step returns a new function, accumulating configuration progressively.

Configuration Builder with Closures

typedef Validator = bool Function(String);

Validator createValidator({
  int? minLength,
  int? maxLength,
  RegExp? pattern,
  List<String>? blacklist,
}) {
  return (String input) {
    if (minLength != null && input.length < minLength) return false;
    if (maxLength != null && input.length > maxLength) return false;
    if (pattern != null && !pattern.hasMatch(input)) return false;
    if (blacklist != null && blacklist.contains(input)) return false;
    return true;
  };
}

/// Compose multiple validators into one that requires all to pass.
Validator composeValidators(List<Validator> validators) {
  return (String input) => validators.every((v) => v(input));
}

void main() {
  final usernameValidator = composeValidators([
    createValidator(minLength: 3, maxLength: 20),
    createValidator(pattern: RegExp(r'^[a-zA-Z0-9_]+$')),
    createValidator(blacklist: ['admin', 'root', 'system']),
  ]);

  print(usernameValidator('edrees_95'));   // true
  print(usernameValidator('ab'));           // false (too short)
  print(usernameValidator('admin'));        // false (blacklisted)
  print(usernameValidator('hello world'));  // false (has space)
}
Tip: The closure-based configuration builder is lighter than a full class when you only need a function as the end result. Use closures for small, focused behaviors. Use classes when you need multiple methods, inheritance, or complex state management.

Closures vs Anonymous Functions vs Named Functions

It is important to understand the relationship between these concepts:

Comparing Function Types

void main() {
  int multiplier = 3;

  // Named function (also a closure if it captures variables)
  int tripleNamed(int x) => x * multiplier;

  // Anonymous function (lambda) that is also a closure
  final tripleAnon = (int x) => x * multiplier;

  // Arrow function shorthand (also a closure)
  final tripleArrow = (int x) => x * multiplier;

  // All three produce the same result:
  print(tripleNamed(5));  // 15
  print(tripleAnon(5));   // 15
  print(tripleArrow(5));  // 15

  // The key: ALL of these are closures because they capture 'multiplier'
  multiplier = 10;
  print(tripleNamed(5));  // 50  (sees the updated multiplier)
}
Note: In Dart, every function is technically a closure. Whether it is named or anonymous, if it can see variables from an outer scope, it captures them. The term “closure” is most commonly used when a function is returned or stored, maintaining access to variables that would otherwise go out of scope.

Practical Example: Fibonacci with Memoization

Combining closures and memoization to optimize the classic Fibonacci sequence demonstrates real-world closure power.

Memoized Fibonacci

/// Creates a memoized Fibonacci function using closures.
int Function(int) createFibonacci() {
  final cache = <int, int>{};

  int fib(int n) {
    if (cache.containsKey(n)) return cache[n]!;
    if (n <= 1) return n;

    final result = fib(n - 1) + fib(n - 2);
    cache[n] = result;
    return result;
  }

  return fib;
}

void main() {
  final fibonacci = createFibonacci();

  // Efficient even for large numbers thanks to memoization
  for (var i = 0; i <= 10; i++) {
    print('fib($i) = ${fibonacci(i)}');
  }
  // fib(0) = 0, fib(1) = 1, fib(2) = 1, ..., fib(10) = 55

  // This would be impossibly slow without memoization
  print('fib(40) = ${fibonacci(40)}');  // 102334155 (instant!)
}
Warning: Be mindful of memory usage with memoization closures. The cache grows indefinitely unless you implement a size limit or eviction strategy. For long-running applications, consider using an LRU (Least Recently Used) cache pattern instead of an unbounded map.

Summary

Closures are a cornerstone of functional programming in Dart. They enable powerful patterns like factory functions, memoization, partial application, and currying. Key takeaways:

  • Closures capture variables (not values) from their lexical scope.
  • Dart’s for loop creates a fresh variable per iteration, avoiding the classic closure-over-loop-variable bug.
  • Partial application fixes some arguments of a function; currying converts to a chain of single-argument functions.
  • Memoization uses a closure’s captured cache map to store previously computed results.
  • Closure factories create independent instances with their own private state, similar to objects but lighter.