Dart Class Modifiers

Why Plain class Is Often Too Permissive in Dart 3

In Dart, every class you expose is also a design promise. A plain class does more than define an object shape; it tells other code that extending, implementing, and depending on that type are all acceptable. That flexibility is useful in small internal code, but it can become expensive when the type represents a domain model, a parser result, a validator contract, or any reusable package boundary. Dart 3 class modifiers exist to make those boundaries explicit before accidental inheritance turns into long-term API debt.

A plain Dart class is more permissive than many engineers realize.

When you write this:

class Money {}

class ConfigValue {}

class Validator<T> {}

you are not just defining a type.

You are allowing external code to:

class CustomMoney extends Money {}

class FakeConfigValue implements ConfigValue {}

class MyValidator implements Validator<String> {}

That may be fine for internal experiments, but it is risky for public APIs.

Once external code extends or implements your class, your implementation details become someone else’s dependency. Adding a method, changing behavior, or tightening invariants can become a breaking change.

Dart 3 class modifiers fix this by making the role of a type explicit.

1. Use final class for concrete implementations

A concrete value type should usually not be externally subclassed.

Example:

final class Money {
  final String currency;
  final double amount;

  const Money(this.currency, this.amount);

  Money add(Money other) {
    if (currency != other.currency) {
      throw ArgumentError('Currency mismatch');
    }

    return Money(currency, amount + other.amount);
  }

  @override
  String toString() {
    return '$currency ${amount.toStringAsFixed(2)}';
  }
}

final class means:

final class Money {}

External code can use Money, but it cannot extend or implement it.

That matters because Money is not a protocol. It is a concrete domain type with invariants:

  • currency must match before addition
  • formatting belongs to the type
  • behavior should not be overridden by external subclasses

Important distinction:

final class Money {}

does not mean immutable.

Immutability comes from the fields and API design:

final String currency;
final double amount;

final class closes the inheritance boundary. final fields protect object state.

2. Use sealed class for closed variant families

Some types are not meant to be open-ended.

For example, a config value might only support a known set of variants:

sealed class ConfigValue {}

final class StringValue extends ConfigValue {
  final String value;
  StringValue(this.value);
}

final class IntValue extends ConfigValue {
  final int value;
  IntValue(this.value);
}

final class BoolValue extends ConfigValue {
  final bool value;
  BoolValue(this.value);
}

final class ListValue extends ConfigValue {
  final List<ConfigValue> value;
  ListValue(this.value);
}

Now Dart knows every direct subtype of ConfigValue.

That enables exhaustive pattern matching:

String renderValue(ConfigValue value) {
  return switch (value) {
    StringValue(:final value) => value,
    IntValue(:final value) => value.toString(),
    BoolValue(:final value) => value.toString(),
    ListValue(value: final values) =>
      '[${values.map(renderValue).join(',')}]',
  };
}

Notice there is no default case:

_ => ...

That is intentional.

With sealed, the analyzer can tell whether every variant has been handled. If a new variant is added later, the switch becomes incomplete and the compiler points to the missing logic.

That is the real value of sealed: it turns forgotten branches into compile-time feedback.

3. Use abstract interface class for contracts

Some types are not implementations. They are boundaries.

Example:

abstract interface class Validator<T> {
  ValidationResult validate(T value);
}

This says:

  • consumers depend on the contract
  • implementations provide their own behavior
  • no one should inherit shared implementation from this type

A concrete implementation can then be explicit:

final class NonEmptyStringValidator implements Validator<String> {
  @override
  ValidationResult validate(String value) {
    if (value.isEmpty) {
      return Invalid('empty');
    }

    return Valid();
  }
}

The result type can also be modeled as a sealed family:

sealed class ValidationResult {}

final class Valid extends ValidationResult {
  @override
  String toString() => 'valid';
}

final class Invalid extends ValidationResult {
  final String reason;

  Invalid(this.reason);

  @override
  String toString() => 'invalid:$reason';
}

This gives a clean architecture:

abstract interface class Validator<T>
sealed class ValidationResult
final class NonEmptyStringValidator implements Validator<String>

Each type has one role.

The interface defines the boundary. The sealed result defines the finite output states. The final implementation performs the actual validation.

4. Use base class only when inherited implementation is intended

base class is different from interface.

Use it when subclasses are supposed to reuse implementation.

base class Parser {
  String normalize(String input) {
    return input.trim();
  }

  Object parse(String input) {
    return normalize(input);
  }
}

base class IntParser extends Parser {
  @override
  Object parse(String input) {
    return int.parse(normalize(input));
  }
}

A base class allows external extension, but prevents external implementation.

That means this is allowed:

base class IntParser extends Parser {}

But this is not allowed outside the library:

class FakeParser implements Parser {}

The reason is simple: base protects implementation inheritance.

If your superclass contains behavior that subclasses are expected to reuse, base is a better signal than a plain class.

The practical decision rule

When designing a Dart API, ask what kind of type you are creating.

Use final class when the type is a concrete implementation.

Use sealed class when the type represents a closed set of variants.

Use abstract interface class when the type is a contract.

Use base class when subclasses should inherit implementation.

Use a plain class only when you intentionally want external code to both extend and implement it.

The mistake is not using class.

The mistake is using class when the API has a stricter architectural role.

Dart 3 class modifiers are not about making code look modern. They are about preventing accidental inheritance, protecting API evolution, and giving the analyzer enough information to catch missing branches before runtime.