Functions in Dart — First-Class, Closures, and Tear-offs
Functions are objects. They can be passed around, returned, and stored — and that changes everything about how we write code.
Functions are objects
In some languages, functions are special. They live in a separate world from data — you can call them, but you cannot touch them.
Dart is different. Every function in Dart is an object. It has a type. It can be assigned to a variable. It can be passed to another function. It can be returned from a function. It can be stored in a list.
void greet() {
print('Hello!');
}
void main() {
var f = greet; // assign to a variable
f(); // call it — prints 'Hello!'
var functions = [greet, greet, greet];
for (var fn in functions) {
fn(); // prints 'Hello!' three times
}
}
This is called being first-class. Functions are first-class citizens in Dart — they have the same rights as any other object.
Why does this matter? Because it unlocks a whole style of programming. Instead of writing repetitive loops, we pass functions to
map and where. Instead of hard-coding behaviour, we accept callbacks. Instead of subclasses, we inject strategies as functions.
Parameters — positional, named, required, and defaults
Dart gives us four ways to define parameters, and knowing when to use each one makes APIs much cleaner.
Required positional — the basics. Every argument must be provided, in order.
int add(int a, int b) {
return a + b;
}
add(2, 3); // 5
add(2); // error — missing argument
Optional positional — wrap in square brackets. Arguments can be omitted; they default to null (or a provided default).
String greet(String name, [String? title]) {
if (title != null) return 'Hello, $title $name';
return 'Hello, $name';
}
greet('Ankit'); // 'Hello, Ankit'
greet('Ankit', 'Dr.'); // 'Hello, Dr. Ankit'
Named parameters — wrap in curly braces. Call-site must use the name, but order doesn't matter. By default they're optional.
void createUser({String? name, int? age}) {
print('$name is $age years old');
}
createUser(age: 25, name: 'Ankit'); // order doesn't matter
createUser(name: 'Ankit'); // age is null
Required named — add the required keyword. The caller must provide it.
void createUser({required String name, int age = 0}) {
print('$name is $age years old');
}
createUser(name: 'Ankit'); // works, age defaults to 0
createUser(age: 25); // error — name is required
The rule of thumb: Use named parameters for functions with more than 2-3 arguments, especially booleans.
createUser(isAdmin: true) is much clearer than createUser(true).
Closures — capturing the environment
A closure is a function that remembers the variables from the scope where it was created — even after that scope has ended.
Function makeCounter() {
int count = 0;
return () {
count++;
return count;
};
}
void main() {
var counter = makeCounter();
print(counter()); // 1
print(counter()); // 2
print(counter()); // 3
}
The inner function captures count. Even after makeCounter returns, the inner function still has access to that variable. Each call to makeCounter() creates a new count, so each counter is independent.
The classic trap — loops and closures. This bites everyone at least once.
var functions = <Function>[];
for (var i = 0; i < 3; i++) {
functions.add(() => print(i));
}
for (var f in functions) {
f(); // prints 3, 3, 3 — not 0, 1, 2!
}
All three closures capture the same i variable. By the time we call them, the loop has finished and i is 3.
The fix: Capture a fresh copy inside the loop.
for (var i = 0; i < 3; i++) {
var captured = i; // fresh copy each iteration
functions.add(() => print(captured));
}
// Now prints 0, 1, 2
Tear-offs — passing functions without the ceremony
When we pass a function to another function, we often write an anonymous wrapper.
var numbers = [1, 2, 3, 4, 5];
// The verbose way
numbers.forEach((n) => print(n));
// The tear-off way
numbers.forEach(print);
Both do the same thing. The second version is called a tear-off — we "tear off" the function from its name and pass it directly.
Tear-offs work for methods too.
class Logger {
void log(String message) => print('[LOG] $message');
}
void main() {
var logger = Logger();
var messages = ['Starting', 'Processing', 'Done'];
// Tear off the method from the instance
messages.forEach(logger.log);
// prints [LOG] Starting, [LOG] Processing, [LOG] Done
}
When to use tear-offs:
• When the function signature matches exactly
• When you don't need to transform the arguments
• When readability improves (usually with well-named functions)
When to use lambdas instead:
• When you need to ignore some arguments
• When you need to transform arguments before passing
• When the logic is more than a simple pass-through
// Tear-off won't work here — we need to transform the argument
numbers.map((n) => n.toString().padLeft(2, '0'));
// Tear-off works here — signature matches exactly
strings.map(int.parse);
Function types and typedef
Every function in Dart has a type. The type describes what goes in and what comes out.
// A function that takes a String and returns an int
int Function(String) parser;
// A function that takes nothing and returns nothing
void Function() callback;
// A function that takes two ints and returns a bool
bool Function(int, int) comparator;
We can use these types anywhere we'd use a regular type — as parameter types, return types, or variable types.
void repeat(int times, void Function() action) {
for (var i = 0; i < times; i++) {
action();
}
}
repeat(3, () => print('Hello')); // prints Hello three times
typedef — naming function types. When function types get long, we can give them a name.
typedef JsonParser = Map<String, dynamic> Function(String);
JsonParser parse = (String json) {
// ... parsing logic
return {};
};
void processJson(JsonParser parser, String input) {
var data = parser(input);
// ...
}
The typedef makes code more readable and self-documenting. It also makes refactoring easier — change the typedef in one place, and the type updates everywhere.
Generic function types. Function types can be generic too.
typedef Mapper<T, R> = R Function(T);
Mapper<int, String> intToString = (i) => i.toString();
Mapper<String, int> stringToInt = (s) => int.parse(s);
Higher-order functions — the functional toolkit
A higher-order function is a function that takes another function as an argument, or returns a function. We've been using them all along.
In Episode 7, we saw map, where, and fold on collections. Now we can see them through the lens of function types.
// map takes a function: T Function(E)
var doubled = [1, 2, 3].map((n) => n * 2);
// where takes a function: bool Function(E)
var evens = [1, 2, 3, 4].where((n) => n.isEven);
// fold takes an initial value and a function: T Function(T, E)
var sum = [1, 2, 3].fold(0, (acc, n) => acc + n);
Writing our own higher-order functions. We can create functions that accept functions.
void retry(int times, void Function() action) {
for (var i = 0; i < times; i++) {
try {
action();
return; // success — exit early
} catch (e) {
if (i == times - 1) rethrow;
print('Attempt ${i + 1} failed, retrying...');
}
}
}
retry(3, () => fetchData());
This pattern — accepting behaviour as a parameter — is everywhere in Dart and Flutter. Button callbacks, animation builders, state notifiers. Functions are the currency of flexible APIs.
Generators — sync* and yield
Sometimes we want to produce a sequence of values lazily — one at a time, on demand. That's what generators do.
Iterable<int> countTo(int n) sync* {
for (var i = 1; i <= n; i++) {
yield i;
}
}
void main() {
for (var n in countTo(5)) {
print(n); // prints 1, 2, 3, 4, 5
}
}
The sync* marker tells Dart this function is a synchronous generator. Instead of returning a value with return, it yields values one at a time with yield.
The key insight: The function pauses at each
yield and resumes when the next value is requested. This means we can generate infinite sequences without running out of memory.
Iterable<int> naturals() sync* {
var n = 0;
while (true) {
yield n++;
}
}
// Take only what we need
var first10 = naturals().take(10);
print(first10.toList()); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
yield* — delegating to another iterable. When we want to yield all values from another iterable, we use yield*.
Iterable<int> flatten(List<List<int>> nested) sync* {
for (var list in nested) {
yield* list; // yield each element from the inner list
}
}
var nested = [[1, 2], [3, 4], [5]];
print(flatten(nested).toList()); // [1, 2, 3, 4, 5]
Generators connect directly to the Streams episode coming up. Asynchronous generators (async*) work the same way but yield values over time — perfect for event streams, WebSocket messages, or sensor data. For now, sync* gives us lazy iteration, and that's already powerful.
Test your understanding
7 questions
Seven questions covering first-class functions, closures, parameters, tear-offs, and generators.