Functions, closures and typedefs

Use named and optional parameters, tear-offs and closures correctly, and give function types names that document their contract.

Parameters and defaults

// positional required, then optional positional, then named
String format(String value, [String prefix = '>', String suffix = '<']) =>
    '$prefix$value$suffix';

// named parameters: required ones must be marked
String greet({required String name, String greeting = 'Hello', int? times}) {
  final count = times ?? 1;
  return List.generate(count, (_) => '$greeting, $name!').join(' ');
}

// a named parameter can also be positional-optional; you cannot mix the two styles
void log(String message, {String level = 'INFO', Object? error}) {
  print('[$level] $message${error == null ? '' : ' ($error)'}');
}

void main() {
  print(format('x'));                       // >x<
  print(format('x', '['));                  // [x<
  print(greet(name: 'ada'));                // Hello, ada!
  print(greet(name: 'ada', times: 2));
  log('started', level: 'DEBUG');
  log('failed', error: StateError('boom'));

  // first-class functions
  const add = _add;                          // a tear-off: a reference, not a call
  final sub = (int a, int b) => a - b;
  print(add(1, 2));
  print(sub(5, 3));

  // a function returning a function
  int Function(int) multiplier(int factor) => (x) => x * factor;
  final triple = multiplier(3);
  print(triple(7));
}

int _add(int a, int b) => a + b;
FormDeclared asCalled as
Required positionalf(int a)f(1)
Optional positionalf([int a = 0])f() or f(1)
Required namedf({required int a})f(a: 1)
Optional namedf({int a = 0})f() or f(a: 1)
Function parameterf(void Function() cb)f(() {})
Typedeftypedef Handler = void Function(String)f(handler)

A default value must be a compile-time constant. When the natural default is an empty collection, use a const [] or a const {}, which is canonicalised and therefore safe to share.

Closures and captured variables

void main() {
  // a closure captures the variable, not a snapshot of its value
  var counter = 0;
  void increment() => counter++;          // sees later changes to counter

  final adders = <int Function(int)>[];
  for (var i = 0; i < 3; i++) {
    adders.add((x) => x + i);             // each closure captures its own i
  }
  print(adders.map((f) => f(100)).toList());   // [100, 101, 102]

  // a function factory keeps state alive after the outer call returns
  Counter makeCounter() {
    var count = 0;
    return Counter(() => ++count, () => count);
  }

  final c1 = makeCounter();
  final c2 = makeCounter();
  c1.bump();
  print('${c1.value()} ${c2.value()}');   // 1 0: separate captured state
}

class Counter {
  Counter(this.bump, this.value);
  final int Function() bump;
  final int Function() value;
}

// tear-offs bind the receiver: this is how you pass a method as a callback
class Greeter {
  Greeter(this.prefix);
  final String prefix;
  String say(String name) => '$prefix $name';
}

void useIt() {
  final g = Greeter('Hi');
  const names = ['ada', 'alan'];
  print(names.map(g.say).toList());        // no lambda needed
}
  • Dart's for loop creates a fresh variable per iteration, so a closure capturing the loop variable behaves as expected. A while loop shares one variable and every closure sees the final value.
  • A captured variable keeps its enclosing scope alive. A long-lived callback holding a large object prevents that object from being collected.
  • A tear-off of an instance method captures the receiver, so it is not a static function. g.say is a closure over g.
  • An immediately invoked function expression is idiomatic when you need a temporary scope: (() { ... })().

Typedefs and function types

// a typedef names a function type, so signatures read as domain language
typedef Json = Map<String, Object?>;
typedef Validator = String? Function(String value);
typedef AsyncLoader<T> = Future<T> Function(String id);

class Field {
  Field(this.name, this.validate);
  final String name;
  final Validator validate;           // far clearer than the raw signature

  String? check(String value) => validate(value);
}

class Repository {
  Repository(this._loader);
  final AsyncLoader<Json> _loader;
  Future<Json> load(String id) => _loader(id);
}

void main() {
  Validator notEmpty = (v) => v.trim().isEmpty ? 'required' : null;
  Validator maxLength(int n) => (v) => v.length > n ? 'too long' : null;

  final composite = (String v) => notEmpty(v) ?? maxLength(10)(v);

  final form = Field('email', composite);
  final long = List.filled(20, 'a').join();
  for (final input in ['', long, '[email protected]']) {
    print('$input -> ${form.check(input) ?? 'ok'}');
  }
}
💡
A typedef is documentation the compiler enforces. When the same function signature appears in three places, name it once: the call sites become shorter, the intent becomes obvious, and changing the signature becomes a single edit.

FAQ

Can I overload a function in Dart?
No, and there are no default parameters in the C++ sense. Use optional named parameters for variants and a factory or named constructor when the return type changes shape.
What is the difference between a function and a closure?
Every function in Dart is a closure. A top-level or static function captures nothing, which is why its tear-off can be a compile-time constant. A lambda captures whatever it references from the enclosing scope.

Dart syntax and null safety Records, patterns and switch expressions

Last refreshed 2026-09-18.