Functions #

Functions are the smallest unit of abstraction that can be named, called repeatedly, and composed. In Dart, functions aren’t just “wrapped procedures” — functions are first-class citizens: they can be stored in variables, passed as parameters, returned as values, and assembled into more complex functions. This ability opens up a highly expressive functional programming style, alongside the already familiar imperative and object-oriented styles. This article covers all aspects of Dart functions from the most basic to asynchronous generators, focusing on when and why to choose one approach over another.

Anatomy of a Function #

Every function in Dart consists of several parts: the return type, the name, the parameter list, and the body. Each has its own rules and implications.

// The most complete form
ReturnType namaFungsi(TipeParam param1, TipeParam param2) {
  // body
  return nilaiYangDikembalikan;
}

// void — returns nothing
void cetakSalam(String nama) {
  print('Hello, $nama!');
}

// Returning a value with an explicit type
int tambah(int a, int b) {
  return a + b;
}

// Arrow function — for a single-expression body
int kali(int a, int b) => a * b;

// Top-level function — defined directly at file level
// callable from anywhere in the same file or after being imported
double hitungPpn(double harga) => harga * 0.11;

Dart infers function return types in some cases, but you should always write them explicitly for public APIs — it’s documentation that can’t go stale:

// ANTI-PATTERN: no explicit return type for a public function
hitungDiskon(double harga) {   // ✗ return type inferred — unclear
  return harga * 0.1;
}

// CORRECT: explicit return type
double hitungDiskon(double harga) {  // ✓ the contract is clear
  return harga * 0.1;
}

Parameters: Four Kinds and When to Use Them #

Dart supports four kinds of parameters that can be combined in one function. Choosing the right parameter kind greatly affects how readable the function calls are.

Positional Parameters (Required, Ordered) #

Parameters that must be provided, where the order determines the value. Good for functions with one or two parameters whose meaning is already clear from their position.

// Two parameters — meaning is clear from the order
double bagi(double pembilang, double penyebut) {
  if (penyebut == 0) throw ArgumentError('Divisor cannot be zero');
  return pembilang / penyebut;
}

print(bagi(10, 2));  // 5.0 — clear: 10 divided by 2
// ANTI-PATTERN: too many positional parameters
void buatPengguna(String nama, String email, int umur, String kota,
    String negara, bool aktif, String peran) {
  // Call: buatPengguna('Budi', '[email protected]', 25, 'Jakarta', 'ID', true, 'admin')
  // ✗ order is hard to remember, arguments easily mixed up
}

// CORRECT: use named parameters for functions with many parameters
void buatPengguna({
  required String nama,
  required String email,
  required int umur,
  String kota = '',
  String negara = 'ID',
  bool aktif = true,
  String peran = 'user',
}) { ... }

// The call becomes self-documenting
buatPengguna(nama: 'Budi', email: '[email protected]', umur: 25, peran: 'admin');

Optional Positional Parameters ([]) #

Parameters that may be omitted, accessed by position, with a null or default value if not provided. Rarely used because they’re less expressive than named parameters.

// Optional positional — only suitable for 1 optional parameter
String formatNama(String depan, [String? belakang]) {
  return belakang != null ? '$depan $belakang' : depan;
}

print(formatNama('Budi'));           // 'Budi'
print(formatNama('Budi', 'Santoso')); // 'Budi Santoso'
// ANTI-PATTERN: several confusing optional positional parameters
void konfigurasikan(String host, [int? port, bool? ssl, int? timeout]) {
  // Call: konfigurasikan('localhost', null, true, 30)
  // ✗ must fill null for port just to set ssl — confusing
}

// CORRECT: use named parameters
void konfigurasikan(String host, {int port = 80, bool ssl = false, int timeout = 30}) { }
// Call: konfigurasikan('localhost', ssl: true, timeout: 30)

Named Parameters ({}) #

Parameters identified by name at the call site — order doesn’t matter. This is the most recommended parameter kind for functions with more than two parameters.

// Named parameters — all with default values
void animasiWidget({
  required Widget child,
  Duration durasi = const Duration(milliseconds: 300),
  Curve kurva = Curves.easeInOut,
  bool putar = false,
  VoidCallback? selesai,
}) {
  // implementation
}

// The call — every argument's purpose is clear
animasiWidget(
  child: const Text('Halo'),
  durasi: Duration(milliseconds: 500),
  selesai: () => print('Animation finished'),
);

required — Mandatory Named Parameters #

Without required, named parameters are always optional. Add required to force callers to provide a value:

// Without required — all optional, easy to forget to fill
void buatOrder({String? idPengguna, List<Item>? items}) {
  // Can be called without arguments — no guarantee of complete data
}

// With required — the compiler forces the caller
void buatOrder({
  required String idPengguna,    // must be provided
  required List<Item> items,     // must be provided
  String? catatanKhusus,         // optional
  bool ekspres = false,          // optional with a default
}) {
  // Here idPengguna and items are guaranteed non-null
}

Combining Parameters #

Positional and named parameters can be combined, but positional must always come first:

// Required positional followed by optional named
String kirimPesan(
  String penerima,          // required positional
  String isi,               // required positional
  {
    bool prioritas = false, // optional named
    DateTime? jadwal,       // optional named nullable
  }
) {
  final prefix = prioritas ? '[PRIORITY] ' : '';
  return '$prefix$penerima: $isi';
}

kirimPesan('Budi', 'Halo!');
kirimPesan('Siti', 'Penting!', prioritas: true);

Arrow Functions #

An arrow function (=>) is shorthand for a function whose body is a single expression. => ekspresi is equivalent to { return ekspresi; }.

// Regular function
int kuadrat(int n) {
  return n * n;
}

// Arrow function — equivalent, more concise
int kuadrat(int n) => n * n;

// Very common as getters and operators
class Lingkaran {
  final double r;
  const Lingkaran(this.r);

  double get luas => 3.14159 * r * r;           // arrow getter
  double get keliling => 2 * 3.14159 * r;        // arrow getter

  bool operator >(Lingkaran lain) => r > lain.r; // arrow operator
}
// ANTI-PATTERN: an arrow function for logic longer than one expression
String kategori(int umur) => umur < 18
    ? 'minor'
    : umur < 60
    ? 'dewasa'    // ✗ nested ternary in an arrow — hard to read
    : 'lansia';

// CORRECT: use a regular body for more complex logic
String kategori(int umur) {
  if (umur < 18) return 'minor';
  if (umur < 60) return 'dewasa';
  return 'lansia';
}

Functions as First-Class Citizens #

In Dart, functions are objects of type Function. This means functions can be stored in variables, operated on like values, and passed as parameters or return values of other functions.

Storing Functions in Variables #

// Type inference — var holds a reference to the function
var tambah = (int a, int b) => a + b;
print(tambah(3, 4)); // 7

// Explicit type using a Function type
int Function(int, int) operasi = (a, b) => a * b;
print(operasi(3, 4)); // 12

// Swap the implementation with the same type
operasi = (a, b) => a + b;
print(operasi(3, 4)); // 7

Functions as Parameters (Higher-Order Functions) #

A function that accepts another function as a parameter is called a higher-order function. This is the foundation of map, where, sort, and nearly all of Dart’s collection methods:

// A simple higher-order function
List<T> filter<T>(List<T> list, bool Function(T) kondisi) {
  return list.where(kondisi).toList();
}

List<int> angka = [1, 2, 3, 4, 5, 6];
List<int> genap = filter(angka, (n) => n.isEven);     // [2, 4, 6]
List<int> besar = filter(angka, (n) => n > 3);        // [4, 5, 6]

// A function that accepts a callback with a specific signature
void jalankanDenganLog(String nama, void Function() aksi) {
  print('Starting: $nama');
  final mulai = DateTime.now();
  aksi();
  final durasi = DateTime.now().difference(mulai);
  print('Finished: $nama (${durasi.inMilliseconds}ms)');
}

jalankanDenganLog('Import data', () {
  importData();  // the action to be logged
});

Functions as Return Values #

// A function that returns a function — a factory for creating operations
int Function(int) pembuatPenambah(int tambahan) {
  return (int n) => n + tambahan;
}

final tambah10 = pembuatPenambah(10);
final tambah100 = pembuatPenambah(100);

print(tambah10(5));   // 15
print(tambah100(5));  // 105
print(tambah10(tambah100(1))); // 111 — composition

// Real-world application: middleware/pipeline
typedef Middleware = String Function(String);

Middleware buatLogger(String prefix) {
  return (String pesan) {
    print('[$prefix] $pesan');
    return pesan; // pass the message to the next middleware
  };
}

Middleware buatSanitizer() {
  return (String pesan) => pesan.trim().replaceAll('<', '&lt;');
}

typedef — Names for Function Types #

typedef defines an alias for a function type, making code more readable and letting function types be reused without rewriting the full signature:

// Without typedef — verbose and hard to read
void prosesData(List<String> data, bool Function(String) validator,
    String Function(String) transformer, void Function(String) output) { }

// With typedef — clean and expressive
typedef Validator<T> = bool Function(T nilai);
typedef Transformer<T, R> = R Function(T nilai);
typedef Consumer<T> = void Function(T nilai);

void prosesData(
  List<String> data,
  Validator<String> validator,
  Transformer<String, String> transformer,
  Consumer<String> output,
) {
  for (final item in data) {
    if (validator(item)) {
      output(transformer(item));
    }
  }
}

// Usage
prosesData(
  ['  Halo  ', '', '  Dart  '],
  (s) => s.trim().isNotEmpty,         // validator
  (s) => s.trim().toUpperCase(),      // transformer
  print,                              // output — direct function reference
);
// Output: HALO
//         DART

typedef is also useful for defining callbacks used in many places:

// Common patterns in Flutter
typedef VoidCallback = void Function();
typedef ValueChanged<T> = void Function(T value);
typedef AsyncCallback = Future<void> Function();

// A class using typedefs as properties
class Tombol {
  final String label;
  final VoidCallback? onTap;
  final ValueChanged<bool>? onHover;

  const Tombol({required this.label, this.onTap, this.onHover});
}

Closures — Capturing State from the Outer Scope #

A closure is a function that “remembers” variables from the scope where it was defined, even after that scope has finished executing. Each closure has its own copy of state.

// Basic closure example
Function buatPenghitung(int mulaiDari) {
  int hitung = mulaiDari;         // the captured variable
  return () {
    return hitung++;              // accesses and modifies the outer variable
  };
}

final hitungA = buatPenghitung(0);
final hitungB = buatPenghitung(10);

print(hitungA()); // 0 — A's own state
print(hitungA()); // 1
print(hitungA()); // 2
print(hitungB()); // 10 — B's state is independent from A
print(hitungA()); // 3 — A continues from before

Closures for Memoization #

Closures are very useful for memoization — caching function results based on their inputs:

// A generic memoize function using a closure
Map<K, V> Function(K) memoize<K, V>(V Function(K) fungsi) {
  final cache = <K, V>{};
  return (K input) {
    return cache.putIfAbsent(input, () => fungsi(input));
  };
}

// Fibonacci without memoization — O(2^n), very slow
int fibNaif(int n) => n <= 1 ? n : fibNaif(n - 1) + fibNaif(n - 2);

// Fibonacci with memoization via a closure — O(n)
final fibMemo = memoize<int, int>((n) {
  if (n <= 1) return n;
  // Recursion uses the same function — can't directly with memoize
  return fibNaif(n - 1) + fibNaif(n - 2); // this is only an illustration
});

The Closure-in-Loop Trap #

// ANTI-PATTERN: a closure in a loop capturing the loop variable
List<Function> fungsi = [];
for (int i = 0; i < 3; i++) {
  fungsi.add(() => print(i)); // ✗ all closures capture a REFERENCE to i
}
// When called after the loop, i is already 3
fungsi[0](); // 3 — not 0!
fungsi[1](); // 3 — not 1!
fungsi[2](); // 3 — not 2!

// CORRECT: capture the value during iteration with a local variable
List<Function> fungsi = [];
for (int i = 0; i < 3; i++) {
  final nilaiI = i;             // copy the value into a new variable
  fungsi.add(() => print(nilaiI)); // ✓ captures the value, not the reference
}
fungsi[0](); // 0 ✓
fungsi[1](); // 1 ✓
fungsi[2](); // 2 ✓

// Or more idiomatically with List.generate
List<Function> fungsi = List.generate(3, (i) => () => print(i));

Recursion #

Recursion is the technique where a function calls itself to break a problem into smaller sub-problems. Every recursive function needs a base case (stopping condition) to prevent infinite recursion.

// Factorial — the classic example
int faktorial(int n) {
  if (n <= 0) throw ArgumentError('n must be positive');
  if (n == 1) return 1;   // base case
  return n * faktorial(n - 1); // recursive case
}

// Fibonacci — two recursive calls
int fibonacci(int n) {
  if (n < 0) throw ArgumentError('n must be >= 0');
  if (n <= 1) return n;   // base case
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// Binary search — recursion on a data structure
int? binarySearch(List<int> list, int target, [int? lo, int? hi]) {
  lo ??= 0;
  hi ??= list.length - 1;

  if (lo > hi) return null;                    // base case: not found
  final mid = lo + (hi - lo) ~/ 2;
  if (list[mid] == target) return mid;         // base case: found
  if (list[mid] < target) return binarySearch(list, target, mid + 1, hi);
  return binarySearch(list, target, lo, mid - 1);
}

Recursion vs Iteration #

// Recursion — elegant but with call-stack overhead
int jumlahRekursif(List<int> list) {
  if (list.isEmpty) return 0;
  return list.first + jumlahRekursif(list.sublist(1));
  // For a 10,000-element list → 10,000 stack frames → Stack Overflow!
}

// Iteration — safer for large data
int jumlahIteratif(List<int> list) {
  return list.fold(0, (acc, n) => acc + n); // ✓ safe for large data
}
// ANTI-PATTERN: recursion without a clear base case
int hitungMundur(int n) {
  print(n);
  return hitungMundur(n - 1); // ✗ no base case — Stack Overflow!
}

// CORRECT: always have a base case that is definitely reached
int hitungMundur(int n) {
  if (n <= 0) return 0;     // base case
  print(n);
  return hitungMundur(n - 1);
}

Generators: sync* and async* #

Generators are special functions that produce a sequence of values lazily — values are only computed when needed, not all at once. Dart supports two kinds of generators.

Synchronous (sync* + yield) #

Synchronous generators produce an Iterable — a sequence of values that can be iterated one at a time:

// Synchronous generator — produces an Iterable<int>
Iterable<int> range(int dari, int ke, {int langkah = 1}) sync* {
  for (int i = dari; i < ke; i += langkah) {
    yield i; // "send" one value, then pause
  }
}

// Usage
for (final n in range(0, 10)) print(n);        // 0 1 2 3 4 5 6 7 8 9
for (final n in range(0, 20, langkah: 2)) print(n); // 0 2 4 6 8 10 12 14 16 18

// Recursive generator for tree traversal
Iterable<int> preorder(TreeNode? node) sync* {
  if (node == null) return;
  yield node.nilai;                // root first
  yield* preorder(node.kiri);     // yield* forwards all values from another Iterable
  yield* preorder(node.kanan);
}

Asynchronous (async* + yield) #

Asynchronous generators produce a Stream — a sequence of async events delivered over time:

// Asynchronous generator — produces a Stream<String>
Stream<String> bacaBarisFile(String path) async* {
  final file = File(path);
  final baris = file.openRead()
      .transform(utf8.decoder)
      .transform(LineSplitter());

  await for (final baris in baris) {
    yield baris; // send one line, then wait until the consumer is ready
  }
}

// Stream with a delay — polling, websocket, sensors
Stream<int> sensorSuhu() async* {
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield bacaSuhuDariSensor(); // send one reading per second
  }
}

// Consumer
await for (final suhu in sensorSuhu().take(10)) {
  print('Temperature: ${suhu}°C');
}

yield* — Delegating to Another Generator #

yield* (yield-star) delegates all values from another Iterable or Stream without needing a manual loop:

// Without yield* — verbose
Iterable<int> gabung(List<Iterable<int>> semua) sync* {
  for (final iterable in semua) {
    for (final item in iterable) {
      yield item;
    }
  }
}

// With yield* — concise
Iterable<int> gabung(List<Iterable<int>> semua) sync* {
  for (final iterable in semua) {
    yield* iterable; // delegate all values from this iterable
  }
}

Asynchronous Functions: async / await #

Functions that perform asynchronous operations (I/O, HTTP, database) are declared with async and return a Future<T>:

// Async function — returns a Future<String>
Future<String> ambilNamaPengguna(String id) async {
  final response = await http.get(Uri.parse('/api/users/$id'));
  if (response.statusCode != 200) {
    throw HttpException('Failed: ${response.statusCode}');
  }
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return json['nama'] as String;
}

// Calling it
Future<void> main() async {
  try {
    final nama = await ambilNamaPengguna('U001');
    print('Hello, $nama!');
  } on HttpException catch (e) {
    print('Error: $e');
  }
}
// ANTI-PATTERN: async without await — returns a Future without waiting
Future<void> simpanData(Data data) async {
  database.insert(data); // ✗ not awaited — the operation may not have finished
  print('Tersimpan'); // prints before the insert actually completes
}

// CORRECT: await all async operations
Future<void> simpanData(Data data) async {
  await database.insert(data); // ✓ wait until it finishes
  print('Tersimpan');
}

Function Composition #

Function composition is the technique of combining several small functions into a data-transformation pipeline. Dart has no built-in composition operator like |> in some languages, but the pattern is easy to implement:

// Three small functions with single responsibilities
String trim(String s) => s.trim();
String uppercase(String s) => s.toUpperCase();
String tambahPrefix(String s) => 'DART: $s';

// Manual composition — nested calls
String hasil = tambahPrefix(uppercase(trim('  halo dunia  ')));
// 'DART: HALO DUNIA'

// More elegant composition with an extension method
extension StringPipeline on String {
  String pipe(String Function(String) fungsi) => fungsi(this);
}

String hasil = '  halo dunia  '
    .pipe(trim)
    .pipe(uppercase)
    .pipe(tambahPrefix);
// 'DART: HALO DUNIA'

// Compose function — creating a combined function
T Function(T) compose<T>(List<T Function(T)> fungsiList) {
  return (T input) => fungsiList.fold(input, (acc, f) => f(acc));
}

final prosesTeks = compose<String>([trim, uppercase, tambahPrefix]);
print(prosesTeks('  dart itu keren  ')); // 'DART: DART ITU KEREN'

Summary #

  • Always use explicit return types for public functions — it’s contract documentation that can’t go stale like comments.
  • Named parameters ({}) are preferred for functions with more than two parameters — calls become self-documenting and order doesn’t matter.
  • required forces callers to provide a named parameter — use it for genuinely mandatory data, avoid making every parameter nullable instead.
  • Arrow functions (=>) for single-expression bodies, regular bodies for logic with more than one step. Don’t force complex logic into a single nested ternary expression.
  • typedef gives meaningful names to function types used repeatedly — making higher-order function signatures much more readable.
  • Closures capture references, not values — in loops, create a local variable copying the loop value to avoid all closures pointing at the final value.
  • Recursion is elegant for naturally recursive problems (tree traversal, divide and conquer), but not safe for large data due to stack depth. Use iteration or fold for simple aggregation.
  • sync* + yield produces an Iterable evaluated lazily — ideal for potentially large or infinite sequences. async* + yield produces a Stream for sequences of async events.
  • yield* delegates all values from another Iterable/Stream — use it in recursive generators instead of manual loops.
  • Asynchronous functions must await all async operations in their body — a forgotten await makes code continue before the operation finishes, a hard-to-trace bug source.

← Previous: Loops   Next: Classes →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact