Ankit Ranjan
Back to Deep Dives

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.

May 23, 2026 7 topics 7 quiz questions
Share:
1

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.

Functions are objects — they can go anywhere objects gogreet()function objectassignvar fvariablegreet()function objectpassotherFn(greet)parameterWhat you can do with a function:• Assign to a variable• Pass as an argument• Return from another function• Store in a collection• Check its type at runtime

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.

2

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
Parameter types at a glanceTypeRequiredfn(int a)Optionalfn([int? a])Namedfn({int? a})Required Namedfn({required})Must provide?YesNoNoYesOrder matters?YesYesNoNoCan have default?NoYesYesYesBest for...core args(always needed)extras infixed orderconfig/options(clarity)important opts(must provide)

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).

3

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.

A closure carries its environment with itmakeCounter() scopecount = 0variable() { ... }inner functioncapturesreturnThe returned closure() { ... }functioncount = 0capturedThe variable travels with the functionEach call to makeCounter() creates a new closurewith its own independent count variable.

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

4

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);

5

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);

6

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);
Higher-order functions transform data through a pipeline[1, 2, 3, 4, 5]input.where()n.isEven→ filter.map()n * 2→ transform.fold()acc + n→ reduce12result[1, 2, 3, 4, 5][2, 4][4, 8]12Each function in the chain accepts a function as its argument.The data flows through; the behaviour is pluggable.

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.

7

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.

Search

Loading search...