Skip to content

Prism

A Prism<S, A> focuses on one variant of a sum type. The focus may or may not be present depending on which variant the value holds, so reads return Option<A>. Unlike an Optional, a Prism also knows how to construct an S from an A — wrapping a value back into the target variant.

A Prism is the right optic for:

  • Sealed class hierarchies where each subclass is a distinct case
  • Discriminated unions or tagged values
  • Extracting and constructing a specific variant without pattern-matching at every call site

Defining a Prism

Prism<S, A> takes two functions:

  • getOrModifyS -> Either<S, A>: Right(a) when the variant matches, Left(s) (the original value, unchanged) when it does not
  • reverseGetA -> S: wrap a back into the matching variant
dart
// A sealed hierarchy representing a configuration value that can be
// a String, an int, or a boolean.
sealed class ConfigValue {}

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

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

final class CBool extends ConfigValue {
  final bool value;
  CBool(this.value);
}
dart
// A Prism<S, A> focuses on one variant of a sum type.
// It requires two functions:
//   getOrModify : S -> Either<S, A>   (Right if the variant matches, Left otherwise)
//   reverseGet  : A -> S              (construct the variant from A)

final stringP = Prism<ConfigValue, String>(
  (cv) => switch (cv) {
    CString(:final value) => Right(value),
    _ => Left(cv),
  },
  CString.new,
);

final intP = Prism<ConfigValue, int>(
  (cv) => switch (cv) {
    CInt(:final value) => Right(value),
    _ => Left(cv),
  },
  CInt.new,
);

final boolP = Prism<ConfigValue, bool>(
  (cv) => switch (cv) {
    CBool(:final value) => Right(value),
    _ => Left(cv),
  },
  CBool.new,
);

Using a Prism

dart
void prismUsage() {
  final ConfigValue cv = CString('hello');

  // getOption — Some when the variant matches, None otherwise
  final str = stringP.getOption(cv); // Some('hello')
  final num = intP.getOption(cv); // None

  // reverseGet — always constructs the target variant
  final constructed = intP.reverseGet(42); // CInt(42)

  // modify — no-op when the variant does not match
  final upper = stringP.modify((s) => s.toUpperCase())(cv); // CString('HELLO')
  final unchanged = intP.modify((n) => n + 1)(cv); // CString('hello') — no match

  // replace — convenience shorthand for modify((_) => value)
  final replaced = stringP.replace('world')(cv); // CString('world')
}

modify and replace are no-ops when the variant does not match — the original value is returned unchanged.

Composing Prisms

andThenP composes two Prisms. The resulting Prism succeeds only when both match:

dart
// andThenP composes two Prisms: both variants must match for the result to succeed.
final positiveIntP = Prism<ConfigValue, int>(
  (cv) => switch (cv) {
    CInt(:final value) when value > 0 => Right(value),
    _ => Left(cv),
  },
  CInt.new,
).andThenP(
  Prism<int, String>(
    (i) => i > 0 ? Right(i.toString()) : Left(i),
    (s) => int.parse(s),
  ),
);

void composeUsage() {
  final cv = CInt(7);
  final str = positiveIntP.getOption(cv); // Some('7')

  final neg = CInt(-1);
  final noStr = positiveIntP.getOption(neg); // None
}

TIP

Dart's exhaustive switch already handles sum-type matching at a single call site. Prism pays off when the same variant is extracted or constructed in multiple places, or when you want to pass the extraction logic as a value (e.g. to a higher-order combinator).