You've seen this in a Freezed class:
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
}) = _User;
}const. factory. A redirecting = _User. A private constructor hiding somewhere in the generated code. If you understand each keyword individually, this declaration makes perfect sense. If you don't, it's a magic incantation you copy from documentation and pray it compiles.
Dart has six kinds of constructors. Most developers use two. The other four show up in Freezed, in BLoC, in singleton patterns, in JSON parsing — and they're not exotic features. They're fundamental tools that solve specific problems. Let's go through all of them.
1. The Default Constructor
The one everybody knows:
class User {
final String name;
final String email;
final int age;
User(this.name, this.email, this.age);
}
final user = User('Alice', 'alice@example.com', 30);this.name in the parameter list is Dart shorthand for "take this argument and assign it to the field name." Without the shorthand, you'd write:
User(String name, String email, int age)
: this.name = name,
this.email = email,
this.age = age;The shorthand saves three lines. The result is identical.
You can also use named parameters — which is what most Flutter code does, because positional arguments become unreadable past three fields:
class User {
final String name;
final String email;
final int age;
User({required this.name, required this.email, required this.age});
}
final user = User(name: 'Alice', email: 'alice@example.com', age: 30);If you've read the function signatures article, you already know the tradeoffs between positional and named parameters. The short version: named parameters are self-documenting and order-independent. For classes with more than two or three fields, they're almost always the right choice.
The Initializer List
A constructor can run validation or compute values before the object is fully created, using the initializer list — the part after the colon, before the body:
class DateRange {
final DateTime start;
final DateTime end;
final Duration duration;
DateRange({required this.start, required this.end})
: assert(end.isAfter(start), 'End must be after start'),
duration = end.difference(start);
}The assert runs in debug mode and catches invalid data early. The duration field is computed from start and end — it's derived, not passed in. Both happen in the initializer list, before the constructor body (if there is one) runs.
The initializer list is where const constructors do their work, and it's where you'll see assert statements in Flutter's own source code. Widget constructors are full of them.
2. Named Constructors
A class can have multiple constructors, each with a different name:
class ApiError {
final String message;
final int statusCode;
final String? detail;
ApiError({required this.message, required this.statusCode, this.detail});
ApiError.notFound(String resource)
: message = '$resource not found',
statusCode = 404,
detail = null;
ApiError.unauthorized()
: message = 'Authentication required',
statusCode = 401,
detail = null;
ApiError.serverError(String detail)
: message = 'Internal server error',
statusCode = 500,
detail = detail;
}Usage:
final error = ApiError.notFound('User #42');
// ApiError(message: 'User #42 not found', statusCode: 404, detail: null)
final authError = ApiError.unauthorized();
// ApiError(message: 'Authentication required', statusCode: 401, detail: null)Named constructors let you express intent. ApiError.notFound('User #42') is clearer than ApiError(message: 'User #42 not found', statusCode: 404). The name tells you what kind of error it is. The constructor fills in the boilerplate.
You see this pattern throughout Flutter's own codebase:
EdgeInsets.all(16.0)
EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0)
EdgeInsets.only(top: 24.0)
BorderRadius.circular(8.0)
Alignment.centerEach is a named constructor (or a const static field, in some cases) that provides a convenient, readable way to create common configurations.
Named constructors are also the foundation of Freezed's union types. When you write:
const factory AuthState.initial() = AuthInitial;
const factory AuthState.loading() = AuthLoading;
const factory AuthState.authenticated({required User user}) = AuthAuthenticated;Each variant is a named constructor on AuthState. The .initial(), .loading(), .authenticated() names are how you create each variant. The syntax is Freezed-specific (factory + redirecting — we'll get to both), but the named constructor concept is standard Dart.
3. Const Constructors
A const constructor enables — but does not force — compile-time constant creation. This distinction matters.
class AppConfig {
final String apiUrl;
final int maxRetries;
final Duration timeout;
const AppConfig({
required this.apiUrl,
required this.maxRetries,
required this.timeout,
});
}The constructor is const. This means:
// Compile-time constant — one object, canonicalized, baked into the binary
const config = AppConfig(
apiUrl: 'https://api.example.com',
maxRetries: 3,
timeout: Duration(seconds: 30),
);
// Runtime instance — allocated normally, tracked by GC
final config = AppConfig(
apiUrl: envUrl, // envUrl is determined at runtime
maxRetries: maxRetries, // from a config file
timeout: timeout, // from user settings
);Both calls use the same constructor. The const keyword on the call site (not the constructor definition) is what determines whether you get a compile-time constant or a runtime instance.
If you've read the const vs final article, you know what canonicalization means: the runtime guarantees only one instance of each unique const expression exists. const AppConfig(apiUrl: 'https://api.example.com', maxRetries: 3, timeout: Duration(seconds: 30)) used in fifty places is one object. Fifty references, one allocation, zero garbage collection.
The Rules
A const constructor has restrictions:
- All fields must be `final`. A
constobject is immutable — you can't have mutable fields. - No body. A
constconstructor can only use the initializer list, not a constructor body. Noifstatements, no loops, no side effects. - All field values must be const-evaluable. You can use literals, other
constobjects, andconstexpressions. You can't call non-const functions. - Superclass must have a `const` constructor too. The entire chain must be const-compatible.
These restrictions exist because the compiler must evaluate the constructor at compile time. If the constructor could run arbitrary code, the compiler couldn't bake the result into the binary.
const in Flutter Widgets
Flutter's StatelessWidget constructors are almost always const:
class WelcomeBanner extends StatelessWidget {
final String title;
final String subtitle;
const WelcomeBanner({
super.key,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
// ...
}
}This enables:
// Compile-time constant — Flutter skips rebuilding this subtree entirely
const WelcomeBanner(title: 'Hello', subtitle: 'Welcome back')
// Runtime instance — rebuilt on every parent rebuild
WelcomeBanner(title: userName, subtitle: greeting)When the parent widget rebuilds, Flutter compares the old and new WelcomeBanner. If both are const with the same arguments, identical() returns true — same object. Flutter skips the entire subtree. This is the const widget optimization that compounds across the widget tree.
4. Factory Constructors
This is the one that confuses people. A factory constructor looks like a constructor but behaves like a static method that returns an instance.
A regular constructor always creates a new instance of its class. A factory constructor doesn't have to. It can return a cached instance, an instance of a subclass, or the result of complex initialization logic.
Why "factory"?
The name comes from the Factory design pattern: a method that creates objects, but the caller doesn't know (or care about) the creation details. In Dart, factory is the keyword that enables this pattern inside a constructor.
The Simplest Use: Caching
class Logger {
static final Map<String, Logger> _cache = {};
final String name;
factory Logger(String name) {
return _cache.putIfAbsent(name, () => Logger._internal(name));
}
Logger._internal(this.name);
void log(String message) => print('[$name] $message');
}Usage:
final a = Logger('auth');
final b = Logger('auth');
print(identical(a, b)); // true — same instance, returned from cacheThe factory constructor checks the cache first. If a Logger with that name already exists, it returns the existing one. If not, it creates a new one using the private Logger._internal() constructor and caches it.
The caller writes Logger('auth') and doesn't know or care whether they got a fresh instance or a cached one. That's the factory pattern — the creation logic is hidden behind the constructor interface.
Returning a Subtype
A factory constructor can return an instance of a subclass:
abstract class Shape {
double get area;
factory Shape.circle(double radius) = Circle;
factory Shape.rectangle(double width, double height) = Rectangle;
}
class Circle extends Shape {
final double radius;
Circle(this.radius);
@override
double get area => 3.14159 * radius * radius;
}
class Rectangle extends Shape {
final double width;
final double height;
Rectangle(this.width, this.height);
@override
double get area => width * height;
}Usage:
final shape = Shape.circle(5.0); // returns a Circle instance
print(shape.area); // 78.53975
print(shape is Circle); // trueThe caller interacts with Shape. The factory constructor decides which concrete type to return. This is exactly what Freezed does — AuthState.loading() returns an AuthLoading instance, not an AuthState directly. The factory constructor on the parent type redirects to the generated subtype.
The Most Common Use: fromJson
class User {
final String name;
final String email;
final int age;
User({required this.name, required this.email, required this.age});
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'] as String,
email: json['email'] as String,
age: json['age'] as int,
);
}
}Why factory here and not a named constructor? Because fromJson needs to run logic — type casting, null checking, default values — before creating the instance. A regular constructor's initializer list can only assign values and run assertions. A factory constructor has a full method body, so it can do anything a function can do.
In practice, fromJson is the factory constructor most Dart developers encounter first. If you've used json_serializable or Freezed's JSON support, the generated _$UserFromJson function is what the factory delegates to.
Regular Constructor vs Factory Constructor
| | Regular constructor | Factory constructor |
|---|---|---|
| Creates a new instance | Always | Optionally — can return cached or existing instances |
| Has access to this | Yes — it's creating the instance | No — this doesn't exist yet |
| Can return a subtype | No | Yes |
| Can have a body | Yes (but const constructors can't) | Yes, always |
| Can be const | Yes | Yes (but only with redirecting syntax) |
| Can use initializer list | Yes | No (no this to initialize) |
The key insight: a regular constructor is the object's creation mechanism. A factory constructor calls the creation mechanism — it's one level of indirection above it.
5. Redirecting Constructors
A redirecting constructor delegates to another constructor in the same class:
class ApiError {
final String message;
final int statusCode;
ApiError(this.message, this.statusCode);
// Redirecting constructors — delegate to the main constructor
ApiError.notFound(String resource) : this('$resource not found', 404);
ApiError.serverError() : this('Internal server error', 500);
}The : this(...) syntax says "call the other constructor with these arguments." The redirecting constructor can't have a body or an initializer list — it does nothing except pass arguments forward.
Redirecting Factory Constructors
This is the form Freezed uses:
abstract class Shape {
factory Shape.circle(double radius) = Circle;
}
class Circle implements Shape {
final double radius;
Circle(this.radius);
}factory Shape.circle(double radius) = Circle; is a redirecting factory constructor. It says: "when someone calls Shape.circle(5.0), actually construct a Circle(5.0)." The = Circle syntax is the redirect.
This is the exact pattern in Freezed:
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
}) = _User; // ← redirecting factory constructor
}const factory User({...}) = _User means: "when someone calls User(name: 'Alice', email: 'alice@example.com'), actually construct a _User with those arguments." _User is the generated implementation class that Freezed creates in user.freezed.dart. It has the fields, the == override, the hashCode, the copyWith — everything.
The const part means callers can write const User(name: 'Alice', email: 'alice@example.com') and get a compile-time constant — because _User also has a const constructor (generated by Freezed).
The factory part means this constructor doesn't create a User directly — it returns a _User, which is a subtype of User. This is legal because _User extends User (in the generated code).
Now every keyword in the Freezed declaration makes sense:
const— enables compile-time constantsfactory— allows returning a subtype (_Userinstead ofUser)= _User— redirects construction to the generated class{required String name, ...}— named parameters (the fields)
No magic. Just four standard Dart features composed together.
6. Private Constructors
A constructor prefixed with underscore is private to the library:
class DatabaseConnection {
static DatabaseConnection? _instance;
final String connectionString;
DatabaseConnection._internal(this.connectionString);
factory DatabaseConnection(String connectionString) {
return _instance ??= DatabaseConnection._internal(connectionString);
}
}This is the singleton pattern. DatabaseConnection._internal() is private — code outside this file can't call it. The only way to get an instance is through the factory constructor, which creates one instance and returns it forever after.
Private Constructors in Freezed
Freezed uses a private constructor for a different reason. If you want custom methods or getters on a Freezed class, you need to add a private empty constructor:
@freezed
class Temperature with _$Temperature {
const Temperature._(); // ← enables custom methods
const factory Temperature({
required double celsius,
}) = _Temperature;
double get fahrenheit => celsius * 9 / 5 + 32;
bool get isFreezing => celsius <= 0;
}Why is const Temperature._() required? Because of how Dart mixins work. The generated mixin _$Temperature assumes the class has no generative constructors other than the factory. When you add a custom getter like fahrenheit, Dart needs the class to be concrete — it needs a generative constructor. The empty private constructor satisfies this requirement without affecting the public API. Callers still use Temperature(celsius: 100), never Temperature._().
Without it, adding a getter or method to a Freezed class produces a compile error. With it, everything works. One line, no visible effect, required by the machinery.
How They Compose
Here's every constructor type, on one class, to show how they work together:
class HttpClient {
final String baseUrl;
final Duration timeout;
final Map<String, String> defaultHeaders;
// 1. Default constructor (with named parameters)
HttpClient({
required this.baseUrl,
this.timeout = const Duration(seconds: 30),
this.defaultHeaders = const {},
});
// 2. Named constructor
HttpClient.forTesting()
: baseUrl = 'http://localhost:3000',
timeout = const Duration(seconds: 5),
defaultHeaders = const {'X-Test': 'true'};
// 3. Redirecting constructor
HttpClient.production() : this(
baseUrl: 'https://api.example.com',
timeout: const Duration(seconds: 30),
defaultHeaders: const {'Accept': 'application/json'},
);
// 4. Private constructor (used by the factory)
HttpClient._cached(this.baseUrl, this.timeout, this.defaultHeaders);
// 5. Factory constructor (caching)
static HttpClient? _instance;
factory HttpClient.shared() {
return _instance ??= HttpClient._cached(
'https://api.example.com',
const Duration(seconds: 30),
const {'Accept': 'application/json'},
);
}
}And usage:
final client = HttpClient(baseUrl: 'https://custom.api.com'); // default
final test = HttpClient.forTesting(); // named
final prod = HttpClient.production(); // redirecting
final shared = HttpClient.shared(); // factory (cached)Four ways to create the same type, each serving a different purpose. The caller picks the one that matches their intent.
The Freezed Declaration, Decoded
Now read a Freezed declaration one more time:
@freezed
class AuthState with _$AuthState {
const AuthState._(); // private constructor — enables custom getters
const factory AuthState.initial() = AuthInitial; // named + const + factory + redirecting
const factory AuthState.loading() = AuthLoading; // same pattern
const factory AuthState.authenticated({ // same pattern, with fields
required User user,
required String token,
}) = AuthAuthenticated;
const factory AuthState.error({
required String message,
}) = AuthError;
bool get isLoggedIn => this is AuthAuthenticated; // custom getter (needs the private constructor)
}Every keyword:
@freezed— tells the code generator to process this classwith _$AuthState— mixes in generated==,hashCode,toString,copyWithconst AuthState._()— private constructor, enables theisLoggedIngetterconst factory AuthState.initial()— const (allows compile-time constants), factory (returns a subtype), named (.initial())= AuthInitial— redirects to the generatedAuthInitialclass
Six constructor concepts. One declaration. No magic — just composition.
Dart constructors are not six different things. They're six variations on a single idea: how an object comes into existence. Default constructors create instances directly. Named constructors express intent. Const constructors enable compile-time evaluation. Factory constructors add indirection. Redirecting constructors delegate. Private constructors control access.
The reason they feel complicated is that most tutorials introduce them in isolation, as disconnected features. In practice, they compose — and the most sophisticated Dart code (Freezed, Flutter's widget system, BLoC patterns) uses them in combination. Understanding each piece individually makes the combination readable, not magical.