IIFEs in Dart are severely underrated and barely anyone seems to agree with me. This is a hill I'm willing to die on, and I've decided to collect my thoughts into a blog post that will hopefully get you on this hill, too.

IIFE stands for Immediately Invoked Function Expression. What does that even mean? Let's start at the beginning. Bear with me. If you already know what IIFEs are, feel free to skip to the use cases.

Table of contents:

What's an expression?

A programming language consists of different entities of abstraction. An expression is one such entity that fulfills the purpose of describing what values your program is going to produce. Examples include literals like 123 or 'hello', variable references, arithmetic like 1 + 2, and function calls.

What's a function?

A function is a collection of statements (and since expressions can be statements, also a collection of expressions). Functions have a name, parameters, a return type, and a body where statements live:

String greet(String name) {
  123; // expression statement
  final message = 'Hello, $name!'; // expression on the right-hand side
  return message;
}

Here, greet is the name, String name is the parameter, the return type is String, and everything between the curly braces is the body. The body contains three statements: an expression statement (123;), a variable declaration with an expression on the right-hand side, and a return statement.

There are many other places where expressions can exist, but these are the most relevant for now.

What's a function expression?

Dart supports anonymous functions by supporting expressions that are functions. Anonymous means no name, and the return type is implicit, it can't be specified and will be inferred automatically:

final a = () {};

final b = (String name) {
  final message = 'Hello, $name!';
  return message;
};

What's an invocation?

To "invoke" something means essentially to call or execute something.

void main() {
  print("Hello World");
}

In that example, print is a function that was invoked, or in other words, called. You can also be very explicit about calling something in Dart and call the call method of a Function:

void main() {
  print.call("Hello World");
}

Think of calling and invoking something as being one and the same thing.

What's immediacy?

What happens if we immediately invoke (call) a function expression? Let me present to you, an immediately invoked function expression:

void main() {
//vvvvv function expression
  () {}();
  //   ^^ invocation
}

That's an expression that is a function expression that is being invoked directly where it was defined.

Admittedly, this looks weird at first sight, but everything in programming does the first time you see it. The question is: what does it give us? And IIFEs give us a whole lot.

IIFE use cases

There are many common annoyances in Dart that are immediately (no pun intended) solved by using IIFEs.

Dart has no if-expression

Dart only supports if statements. Ternary expressions work for simple cases, but they become unreadable with multiple conditions or when you need to execute statements.

With an IIFE, you get an if-expression:

final result = () {
  if (condition1) {
    return "a";
  } else if (condition2) {
    return "b";
  } else {
    return "c";
  }
}();

This is especially useful when initializing final variables that depend on complex logic.

IIFEs reduce mental load by scoping things locally

Consider:

void foo() {
  // Many lines of unrelated code
  // ...
  () {
    final a = 123;
    final b = "abc";
    // ...
  }();
  // Many lines of other unrelated code
  // ...
}

Anything defined in the IIFE doesn't pollute the namespace that comes after it. Without it, you'd have to be careful not to declare or reuse/overwrite values that have been declared before, and you avoid problems where you accidentally shadow names used elsewhere.

This, for example, is an invalid program since foo is declared locally, but the intention is to use the global foo:

void foo() {}

void main() {
  final foo = 123;
  print(foo);
  foo(); // Error: 'foo' isn't a function
}

However, this is fine, since foo only exists within the IIFE:

void foo() {}

void main() {
  () {
    final foo = 123;
    print(foo);
  }();
  foo(); // Works: calls the global foo
}

Region indicators

Most IDEs support collapsible regions. IIFEs are a natural way to tell your IDE that you want something to be collapsible.

Collapsible IIFE in IDE

The markers on the left give you the opportunity to collapse the whole body of a function expression. Why is that useful? If you need to make sense of what's going on in your codebase, it helps to ignore things you've already determined are irrelevant to the problem at hand. Collapsible regions let you do exactly that.

IIFEs give you statements everywhere

For Flutter to be fun, you should know what an IIFE is.

Widget build(BuildContext context) {
  return Column(
    children: [
      () {
        // Complex logic here
        if (isLoading) {
          return CircularProgressIndicator();
        }
        return Text(data);
      }(),
      // More widgets...
    ],
  );
}

Flutter critics tend to complain about deeply nested widget trees. Well, if you use an IIFE, that's no longer a problem. You can always exchange a Flutter widget (which is an expression) for an IIFE, flatten the logic, and return the widget you need.

This also allows you to copy and paste a list of statements into places that support expressions and places that support statements interchangeably.

IIFEs also help reduce widget duplication. If multiple branches of a conditional return the same outer widget, an IIFE lets you factor it out:

// Before: duplicated Card across three branches
if (user.isPremium) {
  return Card(
    elevation: 4,
    margin: EdgeInsets.all(8),
    child: Column(children: [
      Icon(Icons.star, color: user.tier.color),
      Text(user.displayName),
      Text('Member since ${user.joinDate.year}'),
    ]),
  );
} else if (user.isTrialActive) {
  return Card(
    elevation: 4,
    margin: EdgeInsets.all(8),
    child: Column(children: [
      Icon(Icons.hourglass_top),
      Text(user.displayName),
      Text('${user.trialDaysLeft} days left'),
    ]),
  );
} else {
  return Card(
    elevation: 4,
    margin: EdgeInsets.all(8),
    child: Column(children: [
      Icon(Icons.person),
      Text(user.displayName),
      Text('Upgrade to premium'),
    ]),
  );
}

// After: Card and Column factored out with IIFE
Card(
  elevation: 4,
  margin: EdgeInsets.all(8),
  child: Column(
    children: () {
      if (user.isPremium) {
        final memberDuration = DateTime.now().difference(user.joinDate);
        final years = memberDuration.inDays ~/ 365;
        final badge = years >= 5 ? Icons.diamond : Icons.star;
        return [
          Icon(badge, color: user.tier.color),
          Text(user.displayName),
          Text('Member for $years years'),
        ];
      } else if (user.isTrialActive) {
        final daysLeft = user.trialEnd.difference(DateTime.now()).inDays;
        final isUrgent = daysLeft <= 3;
        return [
          Icon(Icons.hourglass_top, color: isUrgent ? Colors.red : null),
          Text(user.displayName),
          Text('$daysLeft days left'),
        ];
      } else {
        return [
          Icon(Icons.person),
          Text(user.displayName),
          Text('Upgrade to premium'),
        ];
      }
    }(),
  ),
)

Yes, you could put the outer widget in a new function, but that adds complexity and increases the mental load. Do you make it public/private? What do you call it? Where do you put it? In my view, having such helper functions is unhelpful since they will only be used in one place. You don't need them at all when you use IIFEs.

IIFEs can return null in widget lists

As u/Dustlay pointed out, IIFEs have an advantage over Builder widgets: they can return null. A WidgetBuilder must return a Widget, so you'd need at least an empty SizedBox(). But in a Row or Column, that empty widget can mess with spacing.

Column(
  children: [
    Text('Header'),
    // Builder can't return null - you'd need SizedBox() which affects spacing
    // Builder(builder: (context) => showExtra ? ExtraWidget() : SizedBox()),

    // IIFE with ?() can return null
    ?() {
      if (!showExtra) return null;
      final data = computeSomething();
      return ExtraWidget(data: data);
    }(),
    Text('Footer'),
  ],
)

The ?() syntax creates a nullable IIFE. When it returns null, the element is omitted from the list entirely. No phantom SizedBox taking up space or interfering with MainAxisAlignment.spaceBetween.

Try-catch as an expression

Dart has no try-catch expression. With an IIFE, you can handle errors and return a value in one go:

final config = () {
  try {
    return jsonDecode(configString);
  } catch (e) {
    return defaultConfig;
  }
}();

This is particularly useful for parsing, file operations, or any fallible initialization where you want a guaranteed value.

Late final with complex initialization

When a late final field needs more than a simple expression to initialize:

class DataProcessor {
  late final Map<String, Handler> _handlers = () {
    final map = <String, Handler>{};
    for (final type in supportedTypes) {
      map[type.name] = type.createHandler();
      map['${type.name}_legacy'] = type.createLegacyHandler();
    }
    return map;
  }();
}

The alternative would be initializing in a constructor or a separate method, but the IIFE keeps the initialization logic right where the field is declared.

More advanced: together with late, IIFEs help you define Excel-style data flow graphs inside of classes without having to add a ton of constructor, initialization, or function declaration boilerplate.

Null-safe value extraction

When you need to safely extract a value through multiple nullable layers:

final userName = () {
  final user = response.data?.user;
  if (user == null) return 'Anonymous';
  if (user.displayName?.isNotEmpty == true) {
    return user.displayName!;
  }
  return user.email?.split('@').first ?? 'User ${user.id}';
}();

This is cleaner than deeply nested ternaries or spreading the logic across multiple statements that pollute your scope.

Switch expressions only support expressions

Dart 3's switch expressions have a limitation: each arm can only contain a single expression. You can't execute statements, declare variables, or add debug logging inside a switch arm.

// This doesn't work - statements aren't allowed in switch expressions
final result = switch (status) {
  Status.loading => {
    print('Loading...'); // Error: statements not allowed
    return LoadingWidget();
  },
  Status.error => ErrorWidget(),
  Status.success => SuccessWidget(),
};

IIFEs solve this elegantly:

final result = switch (status) {
  Status.loading => () {
    print('Loading state entered');
    final message = computeLoadingMessage();
    return LoadingWidget(message: message);
  }(),
  Status.error => () {
    logError(status.error);
    return ErrorWidget(retry: handleRetry);
  }(),
  Status.success => SuccessWidget(data: status.data),
};

Without IIFEs, you'd need to extract each complex arm into a separate function, scattering related logic across your codebase. The IIFE keeps everything inline and readable.

Performance

A common concern: "Don't IIFEs create overhead?" I analyzed this by examining what both the Dart VM and dart2js produce.

If-else benchmark

void main() {
  for (int i = 0; i < 100000; i++) { getX(); getY(); }
  print('Done warming up');
  final sw1 = Stopwatch()..start();
  for (int i = 0; i < 10000000; i++) { getX(); }
  sw1.stop();
  final sw2 = Stopwatch()..start();
  for (int i = 0; i < 10000000; i++) { getY(); }
  sw2.stop();
  print('IIFE: ${sw1.elapsedMicroseconds}us');
  print('Traditional: ${sw2.elapsedMicroseconds}us');
}

@pragma('vm:never-inline')
int getX() {  // IIFE version
  final result = () {
    if (condition()) { return expensive() * 2; }
    else { return 42; }
  }();
  return result;
}

@pragma('vm:never-inline')
int getY() {  // Traditional version
  final int result;
  if (condition()) { result = expensive() * 2; }
  else { result = 42; }
  return result;
}

@pragma('vm:prefer-inline')
bool condition() => DateTime.now().millisecondsSinceEpoch % 2 == 0;
@pragma('vm:prefer-inline')
int expensive() => DateTime.now().microsecondsSinceEpoch;

Run with dart --print-flow-graph-optimized benchmark.dart to see the optimized IL:

getX (IIFE):

B1[function entry]:2
    CheckStackOverflow:8(stack=0, loop=0)
    v75 <- StaticCall:16( _getCurrentMicros@0150898<0> ) T{int}
    Branch if RelationalOp:12(<, v75 T{_Smi}, v41) T{bool} goto (28, 29)
    ...
    Branch if TestInt(v87, v26) goto (5, 6)
B5[target]:20  // condition() returned true
    v68 <- StaticCall:16( _getCurrentMicros@0150898<0> ) T{int}
    v18 <- BinarySmiOp:24(<<, v68 T{_Smi}, v26) T{_Smi}  // expensive() * 2
    goto B7
B6[target]:30  // condition() returned false
    goto B7
B7[join]:19 pred(B5, B6) {
    v27 <- phi(v18 T{_Smi}, v24 T{_Smi})  // v24 is constant 42
}
    DartReturn:22(v27)

getY (Traditional):

B1[function entry]:2
    CheckStackOverflow:8(stack=0, loop=0)
    v57 <- StaticCall:16( _getCurrentMicros@0150898<0> ) T{int}
    Branch if RelationalOp:12(<, v57 T{_Smi}, v22) T{bool} goto (26, 27)
    ...
    Branch if TestInt(v69, v23) goto (3, 4)
B3[target]:22  // condition() returned true
    v50 <- StaticCall:16( _getCurrentMicros@0150898<0> ) T{int}
    v78 <- BinarySmiOp:26(<<, v50 T{_Smi}, v23) T{_Smi}  // expensive() * 2
    goto B5
B4[target]:28  // condition() returned false
    goto B5
B5[join]:32 pred(B3, B4) {
    v5 <- phi(v78 T{_Smi}, v4)  // v4 is constant 42
}
    DartReturn:40(v5)

The structure is identical. The IIFE is completely inlined with no closure allocation or call overhead.

Switch expression benchmark

enum Status { loading, error, success }

void main() {
  final statuses = [Status.loading, Status.error, Status.success];
  for (int i = 0; i < 100000; i++) {
    getWithIIFE(statuses[i % 3]);
    getTraditional(statuses[i % 3]);
  }
  print('Done warming up');
  final sw1 = Stopwatch()..start();
  for (int i = 0; i < 10000000; i++) { getWithIIFE(statuses[i % 3]); }
  sw1.stop();
  final sw2 = Stopwatch()..start();
  for (int i = 0; i < 10000000; i++) { getTraditional(statuses[i % 3]); }
  sw2.stop();
  print('Switch+IIFE: ${sw1.elapsedMicroseconds}us');
  print('Traditional: ${sw2.elapsedMicroseconds}us');
}

@pragma('vm:never-inline')
String getWithIIFE(Status status) {  // Switch expression with IIFEs
  return switch (status) {
    Status.loading => () {
      final msg = computeMessage('load');
      return 'Loading: $msg';
    }(),
    Status.error => () {
      final code = getErrorCode();
      return 'Error $code: ${computeMessage('err')}';
    }(),
    Status.success => 'OK',
  };
}

@pragma('vm:never-inline')
String getTraditional(Status status) {  // Traditional switch statement
  switch (status) {
    case Status.loading: return 'Loading: ${computeMessage('load')}';
    case Status.error: return 'Error ${getErrorCode()}: ${computeMessage('err')}';
    case Status.success: return 'OK';
  }
}

@pragma('vm:prefer-inline')
String computeMessage(String prefix) => '$prefix-${DateTime.now().millisecond}';
@pragma('vm:prefer-inline')
int getErrorCode() => DateTime.now().second;

The optimized IL for both versions is structurally identical, just like the if-else case. The VM inlines IIFEs in switch expression arms just as effectively.

dart2js output

Compile with dart compile js -O2 -o out.js file.dart and inspect the output:

Traditional version (inlined directly):

// Cleaned up:
getY() {
  var result;
  if (condition()) {
    result = expensive() * 2;
  } else {
    result = 42;
  }
  return result;
}

// Actual minified output:
// cg(){var t,s=A.cb()
// if(A.ak(new A.H(Date.now(),0,!1))>500){Date.now()
// t=0}else t=42
// A.ci("IIFE: "+s+", Traditional: "+t)}

IIFE version (closure as prototype method):

// Cleaned up:
getX() {
  return new A.ai().$0();  // <-- allocates closure, calls $0
}

A.ai.prototype = {
  $0() {
    if (condition()) {
      return expensive() * 2;
    } else {
      return 42;
    }
  }
}

// Actual minified output:
// cb(){return new A.ai().$0()},
// A.ai.prototype={
// $0(){if(A.ak(new A.H(Date.now(),0,!1))>500){Date.now()
// return 0}else return 42},
// $S:0}

The IIFE version has a small overhead: new A.ai().$0() allocates a closure object and calls through $0. However, V8 and other modern JS engines inline these aggressively, so benchmarks show no measurable difference in practice.

Bottom line: IIFEs have zero runtime cost in optimized Dart VM code, and negligible cost in JavaScript. Use them freely.

Conclusion

IIFEs are a simple concept with broad applications. They give you expressions where Dart only offers statements, scoping where Dart gives you a flat namespace, and flexibility where the language is rigid.

The syntax () {}() might look odd at first, but once you internalize it, you'll start seeing opportunities everywhere: that complex ternary that's getting out of hand, that variable leaking into scope where it shouldn't, that widget tree begging for a bit of logic.

Dart 3 added switch expressions and if-case patterns, which cover some of the ground IIFEs used to own. But IIFEs remain more general. They're not a feature the language gave you; they're a pattern that emerges from first principles. And patterns that emerge from first principles tend to stick around.

Give IIFEs a chance. Your code will thank you.


PS: IIFEs emerged as a concept in the JavaScript world (MDN reference) where they are very common. The thing about Dart is that IIFEs are even cleaner than in JS. To use a function expression with a function body, you don't need any function keywords in Dart, they just work. And you can immediately call them without wrapping parentheses. That's actually very cool, and I can't imagine a cleaner syntax for IIFEs than what Dart offers. I'd love to see the Dart community not reinvent the wheel, but actually use the wheel we have, because our wheel is even better.

PPS: IIFEs increase the expressivity of statements and expressions by providing a way to merge them. Let's assume we don't know what IIFEs are and our language consists only of statements and expressions: We can't put arbitrary statements into arbitrary expressions with statements and expressions in our language only. Once we add IIFEs to our vocabulary, we can always put arbitrary statements into arbitrary expressions. Similar to how expression statements allow you to put expressions into statements, IIFEs can be seen as a "Statement Expression" because they allow you to put statements into expressions.


Discuss on Reddit