1.3 Algebraic Data Types and Type Inference: Making State Space Visible
A product type expresses that multiple fields are present simultaneously, while a sum type indicates that the value can only be one of several variants. Together, they make the number of possible state combinations explicit and manageable.
Product and Sum Correspond to Multiplication and Addition
record Point(int x, int y)If x has X possible values and y has Y independently selectable values, the pair has X × Y combinations. Additional cross-field constraints can reduce that state space.
PaymentResult = Approved(receipt) | Declined(reason) | Pending(reviewId)The value counts of the distinct variants add up. Each variant owns its payload, so an approved result has no decline-reason field. This does not automatically exclude a null receipt in a language that permits null references; the constructor must enforce that rule.
Java can express this using sealed hierarchies, records, and pattern matching; Rust uses enums, and TypeScript employs discriminated unions. The useful properties are a closed variant set and a payload attached to each state.
Exhaustive Matching Exposes New States
Save this as PaymentDemo.java and run java PaymentDemo.java with JDK 21 or later. The example uses finalized record patterns and pattern matching for switch, without preview flags.
import java.util.Objects;
class PaymentDemo {
sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String receipt) implements PaymentResult {
Approved { Objects.requireNonNull(receipt); }
}
record Declined(String reason) implements PaymentResult {
Declined { Objects.requireNonNull(reason); }
}
record Pending(String reviewId) implements PaymentResult {
Pending { Objects.requireNonNull(reviewId); }
}
static String describe(PaymentResult result) {
Objects.requireNonNull(result);
return switch (result) {
case Approved(var receipt) -> "receipt:" + receipt;
case Declined(var reason) -> "declined:" + reason;
case Pending(var id) -> "pending:" + id;
};
}
public static void main(String[] args) {
System.out.println(describe(new Approved("r-1")));
System.out.println(describe(new Declined("limit")));
System.out.println(describe(new Pending("p-1")));
}
}The output lines are receipt:r-1, declined:limit, and pending:p-1. Calling new Approved(null) or describe(null) fails immediately with NullPointerException. Empty strings still pass these constructors; reject them too if the domain requires nonblank values.
Add a Cancelled record implementing PaymentResult and include it in permits. The unchanged switch must then fail compilation because it is no longer exhaustive. A blanket default -> null would hide that missing case. For an open external protocol, an explicit unknown variant can preserve the original input and allow deliberate handling.
Optional and Result Represent Absence and Failure
Optional<T> represents an optional value. A Result<T,E> abstraction represents success or a typed failure; it is not a standard Java type with that name. These abstractions need not replace every exception. Invalid arguments, business refusals, and infrastructure failures can call for different recovery policies.
Use Optional<List<T>> when an absent field and a present empty list have different meanings. Otherwise, an empty list may be enough. Add wrappers to represent a distinction the caller actually needs.
Type Inference Solves Constraints
The compiler gathers equality and subtype constraints from literals, parameters, return contexts, and bounds, then solves for a suitable type. Local inference reduces repetition; explicit types can still help readers understand a public API.
emptyList() may be inferred as List<String> or List<Order> depending on contextWithout enough context, the compiler applies its language-specific defaults or reports an error. Add an explicit type to an intermediate value when a long expression fails: it narrows down which constraint is wrong. Type inference can save keystrokes; it still cannot read your mind.
Type Systems Can't Replace Runtime Invariants
If Email is only an alias for String, its name does not validate an address. A smart constructor can restrict construction:
parseEmail(untrusted) -> Result<Email, ValidationError>Check how each actual boundary constructs values. Some serializers or ORM paths bypass ordinary constructors; others invoke them and preserve their checks. Database migrations may change stored data without running application constructors. Validate important invariants at those boundaries and add storage constraints where appropriate.
Type systems also cannot automatically prove balance conservation, permission policies, or distributed temporal ordering. While stronger refinement or dependent types can express more properties, they come with increased proof and tooling overhead. Choose the level of assurance that aligns with the associated risks.
Modeling Checks
- Which field combinations actually represent mutually exclusive states?
- Is
nullmissing, unknown, not loaded, or failed? - After adding a variant, which consumers must explicitly update?
- How should external open enumerations handle future unknown values?
- Does the constructor guarantee internal value validity, or could boundary conditions be bypassed?
- Does type inference make public contracts or error locations ambiguous?
The next chapter moves into the runtime behind types: the heap, frame, and reference semantics in specifications are not the same as actual JIT behavior, GC, or stack-based optimizations.
References
Oracle, JLS: Type Inference
OpenJDK, JEP 441: Pattern Matching for switch
OpenJDK, JEP 440: Record Patterns