Closures & Currying
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)
}
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
}
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)
}
}
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)
}
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)
}
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!)
}
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
forloop 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.