1.3 Algebraic Data Types and Type Inference: Making State Space Visible
The Developer Workshop needs to represent a result that is either "success," "pending," or "failure." Ah Hua doesn't want to rely on a few potentially conflicting boolean flags to encode state.
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 possible values, then Point has X × Y distinct combinations.
PaymentResult = Approved(receipt) | Declined(reason) | Pending(reviewId)The total number of possible values across variants is the sum of each variant's values. Compared to status + nullable receipt + nullable reason, sum types prevent invalid combinations such as "Approved with an empty receipt and a non-empty reason."
Java can express this using sealed hierarchies, records, and pattern matching; Rust uses enums, and TypeScript employs discriminated unions. The key isn't syntax; it's that the variant set is closed and that the payload is tightly bound to its state.
Exhaustive matching makes new states explicitly propagate
return switch (result) {
case Approved(var receipt) -> show(receipt);
case Declined(var reason) -> explain(reason);
case Pending(var id) -> track(id);
};After adding Cancelled, the compiler can identify unhandled cases. Adding default -> null actively removes this evolutionary aid. Only introduce an unknown branch in externally exposed protocols where forward compatibility is truly required, and preserve the original value along with telemetry.
Optional/Result Putting Missing Values and Failures into Types
Optional<T> expressions represent the possibility of no value, and Result<T,E> expressions represent successful outcomes or typed failures. They should not replace all exceptions, handling strategies for parameter validation failures, business-level denials, and infrastructure crashes are fundamentally different.
Avoid Optional<List<T>> such unnecessary dual null states unless "field not provided" and "provided empty list" genuinely convey distinct semantics. Types should reflect business meaning, not stack multiple wrappers on top of each other.
Type inference: Removing constraints, not reading minds
The compiler gathers equality and subtype constraints from literals, parameters, return contexts, and bounds, then computes a type that satisfies those constraints. Local type inference helps reduce redundancy, but it should never obscure the contractual agreements of public APIs.
emptyList() may be inferred as List<String> or List<Order> depending on contextWithout sufficient context, the compiler can only choose a default or most permissive type, or fail. When complex chained expressions go wrong, adding explicit types to intermediate values serves as a diagnostic anchor, not as an admission of failed inference.
Type Systems Can't Replace Runtime Invariants
Email If a type is merely a String alias, it doesn't guarantee format validation. Smart constructors can enforce construction rules:
parseEmail(untrusted) -> Result<Email, ValidationError>However, database migrations, deserialization, reflection, and ORM operations can still bypass construction paths. Critical invariants must be validated at boundaries, and where necessary, constraints should also be enforced at the storage layer.
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
- Oracle, JLS: Sealed Classes and Interfaces
- Rust, Enums and Pattern Matching