Dart Object-Oriented Programming

Callable Classes & the call() Method

40 min Lesson 15 of 24

What Makes a Class Callable?

In Dart, a class becomes callable -- meaning you can use its instances like functions -- by implementing a special method named call(). When you define a call() method on a class, Dart allows you to invoke instances of that class using function call syntax with parentheses.

This is a powerful pattern that blurs the line between objects and functions, giving you the benefits of both: the stateful behavior of objects and the concise syntax of function calls.

Your First Callable Class

class Greeter {
  final String greeting;

  Greeter(this.greeting);

  // The call() method makes this class callable
  String call(String name) {
    return '$greeting, $name!';
  }
}

void main() {
  final hello = Greeter('Hello');
  final marhaba = Greeter('Marhaba');

  // Call instances like functions!
  print(hello('Alice'));     // Hello, Alice!
  print(marhaba('Ahmad'));   // Marhaba, Ahmad!

  // You can also call it explicitly
  print(hello.call('Bob'));  // Hello, Bob!
}
Key Concept: The call() method can have any return type, any number of parameters, and can even be generic. There is no interface to implement -- just define a method named call and Dart handles the rest.

Closures vs Callable Classes

Dart closures (anonymous functions) can capture variables from their surrounding scope, which gives them state. Callable classes achieve the same thing but with more structure, testability, and reusability.

Comparing Closures and Callable Classes

// Approach 1: Closure with state
Function makeCounter() {
  int count = 0;
  return () {
    count++;
    return count;
  };
}

// Approach 2: Callable class with state
class Counter {
  int _count = 0;

  int call() {
    _count++;
    return _count;
  }

  // Bonus: callable classes can have extra methods!
  void reset() => _count = 0;
  int get current => _count;

  @override
  String toString() => 'Counter(count: $_count)';
}

void main() {
  // Closure approach
  final closureCounter = makeCounter();
  print(closureCounter());  // 1
  print(closureCounter());  // 2
  // Can't reset or inspect the closure...

  // Callable class approach
  final counter = Counter();
  print(counter());         // 1
  print(counter());         // 2
  print(counter.current);   // 2 -- inspect state
  counter.reset();          // reset state
  print(counter());         // 1 -- starts over
}
When to Choose Which: Use closures for simple, one-off functions that need minimal state. Use callable classes when you need: (1) multiple methods besides the main behavior, (2) testable and mockable objects, (3) the ability to inspect or reset state, or (4) to implement interfaces.

Practical Example: Validators

Callable classes shine as validators -- each validator is an object with configurable rules that you call on input values. This pattern is used extensively in form validation.

Form Validators as Callable Classes

abstract class Validator {
  String? call(String? value);
}

class RequiredValidator extends Validator {
  final String message;

  RequiredValidator([this.message = 'This field is required']);

  @override
  String? call(String? value) {
    if (value == null || value.trim().isEmpty) {
      return message;
    }
    return null; // null means valid
  }
}

class MinLengthValidator extends Validator {
  final int minLength;
  final String? customMessage;

  MinLengthValidator(this.minLength, [this.customMessage]);

  @override
  String? call(String? value) {
    if (value != null && value.length < minLength) {
      return customMessage ?? 'Must be at least $minLength characters';
    }
    return null;
  }
}

class EmailValidator extends Validator {
  @override
  String? call(String? value) {
    if (value == null || value.isEmpty) return null;
    final emailRegex = RegExp(r'^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\.[a-zA-Z]+$');
    if (!emailRegex.hasMatch(value)) {
      return 'Please enter a valid email address';
    }
    return null;
  }
}

class PatternValidator extends Validator {
  final RegExp pattern;
  final String message;

  PatternValidator(this.pattern, this.message);

  @override
  String? call(String? value) {
    if (value != null && !pattern.hasMatch(value)) {
      return message;
    }
    return null;
  }
}

// Compose multiple validators
class CompositeValidator extends Validator {
  final List<Validator> validators;

  CompositeValidator(this.validators);

  @override
  String? call(String? value) {
    for (final validator in validators) {
      final error = validator(value);
      if (error != null) return error;
    }
    return null;
  }
}

void main() {
  // Create validators
  final required = RequiredValidator();
  final minLength = MinLengthValidator(8);
  final email = EmailValidator();

  // Use them like functions
  print(required(''));        // This field is required
  print(required('hello'));   // null (valid)
  print(minLength('abc'));    // Must be at least 8 characters
  print(email('bad-email'));  // Please enter a valid email address
  print(email('a@b.com'));    // null (valid)

  // Compose validators for a password field
  final passwordValidator = CompositeValidator([
    RequiredValidator('Password is required'),
    MinLengthValidator(8, 'Password must be at least 8 characters'),
    PatternValidator(
      RegExp(r'[A-Z]'),
      'Password must contain at least one uppercase letter',
    ),
    PatternValidator(
      RegExp(r'[0-9]'),
      'Password must contain at least one number',
    ),
  ]);

  print(passwordValidator(''));          // Password is required
  print(passwordValidator('abc'));        // Password must be at least 8 characters
  print(passwordValidator('abcdefgh'));   // Password must contain at least one uppercase letter
  print(passwordValidator('Abcdefg1'));   // null (valid!)
}
Pattern Insight: The CompositeValidator is itself a callable class that contains other callable classes. This is the Composite Pattern -- treating individual objects and collections of objects uniformly. You can nest validators arbitrarily deep.

Practical Example: Formatters

Callable classes work beautifully as formatters -- objects that transform data into a display-ready string. Each formatter can have configuration that affects how it formats.

Data Formatters as Callable Classes

class CurrencyFormatter {
  final String symbol;
  final int decimalPlaces;
  final String thousandsSeparator;

  CurrencyFormatter({
    this.symbol = '\$',
    this.decimalPlaces = 2,
    this.thousandsSeparator = ',',
  });

  String call(num amount) {
    final fixed = amount.toStringAsFixed(decimalPlaces);
    final parts = fixed.split('.');
    final intPart = parts[0];
    final decPart = parts.length > 1 ? '.${parts[1]}' : '';

    // Add thousands separators
    final buffer = StringBuffer();
    for (int i = 0; i < intPart.length; i++) {
      if (i > 0 && (intPart.length - i) % 3 == 0) {
        buffer.write(thousandsSeparator);
      }
      buffer.write(intPart[i]);
    }

    return '$symbol${buffer.toString()}$decPart';
  }
}

class DateFormatter {
  final String format;

  DateFormatter([this.format = 'yyyy-MM-dd']);

  String call(DateTime date) {
    return format
        .replaceAll('yyyy', date.year.toString().padLeft(4, '0'))
        .replaceAll('MM', date.month.toString().padLeft(2, '0'))
        .replaceAll('dd', date.day.toString().padLeft(2, '0'))
        .replaceAll('HH', date.hour.toString().padLeft(2, '0'))
        .replaceAll('mm', date.minute.toString().padLeft(2, '0'));
  }
}

class Slugify {
  final String separator;

  Slugify([this.separator = '-']);

  String call(String text) {
    return text
        .toLowerCase()
        .trim()
        .replaceAll(RegExp(r'[^\w\s-]'), '')
        .replaceAll(RegExp(r'[\s_]+'), separator);
  }
}

void main() {
  final usd = CurrencyFormatter();
  final eur = CurrencyFormatter(symbol: '€', thousandsSeparator: '.');
  final formatDate = DateFormatter('dd/MM/yyyy');
  final slugify = Slugify();

  print(usd(1234567.89));   // $1,234,567.89
  print(eur(1234567.89));   // €1.234.567.89
  print(formatDate(DateTime(2024, 3, 15)));  // 15/03/2024
  print(slugify('Hello World! This is Dart'));  // hello-world-this-is-dart
}

Practical Example: Middleware & Event Handlers

In application architecture, callable classes are perfect for middleware (processing pipelines) and event handlers -- each step is a configurable object that you chain together.

Middleware Pipeline with Callable Classes

class Request {
  final String path;
  final Map<String, String> headers;
  bool isAuthenticated;
  String? userId;

  Request(this.path, {Map<String, String>? headers})
      : headers = headers ?? {},
        isAuthenticated = false;

  @override
  String toString() => 'Request($path, auth: $isAuthenticated, user: $userId)';
}

class Response {
  final int statusCode;
  final String body;
  const Response(this.statusCode, this.body);

  @override
  String toString() => 'Response($statusCode: $body)';
}

// Each middleware is a callable class
typedef NextHandler = Response Function(Request);

class LoggingMiddleware {
  Response call(Request request, NextHandler next) {
    print('[LOG] ${DateTime.now()}: ${request.path}');
    final response = next(request);
    print('[LOG] Response: ${response.statusCode}');
    return response;
  }
}

class AuthMiddleware {
  final Set<String> validTokens;

  AuthMiddleware(this.validTokens);

  Response call(Request request, NextHandler next) {
    final token = request.headers['Authorization'];
    if (token != null && validTokens.contains(token)) {
      request.isAuthenticated = true;
      request.userId = 'user_${token.hashCode}';
      return next(request);
    }
    return Response(401, 'Unauthorized');
  }
}

class RateLimiter {
  final int maxRequests;
  final Map<String, int> _requestCounts = {};

  RateLimiter(this.maxRequests);

  Response call(Request request, NextHandler next) {
    final ip = request.headers['X-Forwarded-For'] ?? 'unknown';
    _requestCounts[ip] = (_requestCounts[ip] ?? 0) + 1;
    if (_requestCounts[ip]! > maxRequests) {
      return Response(429, 'Too Many Requests');
    }
    return next(request);
  }
}

void main() {
  final logger = LoggingMiddleware();
  final auth = AuthMiddleware({'token-abc', 'token-xyz'});
  final limiter = RateLimiter(100);

  // Build a handler pipeline
  Response handler(Request req) => Response(200, 'Welcome, ${req.userId}!');

  final request = Request(
    '/api/profile',
    headers: {'Authorization': 'token-abc', 'X-Forwarded-For': '192.168.1.1'},
  );

  // Chain middleware manually
  final response = logger(
    request,
    (req) => auth(req, (req) => limiter(req, handler)),
  );

  print(response);  // Response(200: Welcome, user_...)
}
Architecture Tip: Callable classes as middleware give you testability (test each middleware in isolation), composability (mix and match middleware), and configurability (each middleware can have its own settings like rate limits, allowed tokens, etc.).

Generic Callable Classes

The call() method can use generics, making your callable classes even more flexible.

Generic Transformer and Memoizer

// A generic transformer that maps one type to another
class Transformer<TInput, TOutput> {
  final TOutput Function(TInput) _transform;

  Transformer(this._transform);

  TOutput call(TInput input) => _transform(input);
}

// A memoized callable -- caches results for repeated inputs
class Memoize<TInput, TOutput> {
  final TOutput Function(TInput) _fn;
  final Map<TInput, TOutput> _cache = {};

  Memoize(this._fn);

  TOutput call(TInput input) {
    return _cache.putIfAbsent(input, () => _fn(input));
  }

  void clearCache() => _cache.clear();
  int get cacheSize => _cache.length;
}

void main() {
  // Type-safe transformer
  final toUpper = Transformer<String, String>((s) => s.toUpperCase());
  final toLength = Transformer<String, int>((s) => s.length);

  print(toUpper('hello'));    // HELLO
  print(toLength('hello'));   // 5

  // Memoized expensive computation
  final fibonacci = Memoize<int, int>((n) {
    if (n <= 1) return n;
    // Note: this naive version is for demo only
    int a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
      final temp = a + b;
      a = b;
      b = temp;
    }
    return b;
  });

  print(fibonacci(10));           // 55
  print(fibonacci(10));           // 55 (from cache)
  print(fibonacci.cacheSize);    // 1
}
Caution: While callable classes are powerful, do not overuse them. If a plain function suffices and you do not need state, configuration, or extra methods, use a regular function. Callable classes add complexity that should be justified by the benefits they provide.

Real-World Example: Event System

Here is a complete example showing callable classes as event handlers in a publish-subscribe system -- a pattern found in many Flutter applications.

Event System with Callable Handlers

class Event {
  final String type;
  final Map<String, dynamic> data;
  final DateTime timestamp;

  Event(this.type, [Map<String, dynamic>? data])
      : data = data ?? {},
        timestamp = DateTime.now();

  @override
  String toString() => 'Event($type, $data)';
}

// Callable event handler
abstract class EventHandler {
  void call(Event event);
}

class LogHandler extends EventHandler {
  @override
  void call(Event event) {
    print('[${event.timestamp}] ${event.type}: ${event.data}');
  }
}

class ThrottledHandler extends EventHandler {
  final EventHandler _inner;
  final Duration cooldown;
  DateTime? _lastCall;

  ThrottledHandler(this._inner, this.cooldown);

  @override
  void call(Event event) {
    final now = DateTime.now();
    if (_lastCall == null || now.difference(_lastCall!) >= cooldown) {
      _lastCall = now;
      _inner(event); // Call the inner handler
    }
  }
}

class FilteredHandler extends EventHandler {
  final EventHandler _inner;
  final bool Function(Event) _predicate;

  FilteredHandler(this._inner, this._predicate);

  @override
  void call(Event event) {
    if (_predicate(event)) {
      _inner(event);
    }
  }
}

class EventBus {
  final Map<String, List<EventHandler>> _handlers = {};

  void on(String eventType, EventHandler handler) {
    _handlers.putIfAbsent(eventType, () => []).add(handler);
  }

  void emit(String eventType, [Map<String, dynamic>? data]) {
    final event = Event(eventType, data);
    final handlers = _handlers[eventType] ?? [];
    for (final handler in handlers) {
      handler(event); // Call each handler like a function
    }
  }
}

void main() {
  final bus = EventBus();
  final logger = LogHandler();

  // Register handlers
  bus.on('user.login', logger);
  bus.on('user.login', FilteredHandler(
    LogHandler(),
    (e) => e.data['role'] == 'admin',
  ));

  // Emit events
  bus.emit('user.login', {'name': 'Alice', 'role': 'user'});
  // Only the regular logger fires

  bus.emit('user.login', {'name': 'Bob', 'role': 'admin'});
  // Both loggers fire for admin login
}
Summary: Callable classes use the call() method to make instances invocable with function syntax. They are superior to closures when you need state management, extra methods, testability, or interface compliance. Common use cases include validators, formatters, middleware, transformers, and event handlers. Use them when objects need to behave like functions with added capabilities.