Talking about chapter 6, I want to add a question on the part where the author states "of course, we could add special runtime validation checks to make sure that this couldn’t happen."

The question is: Aren’t the check pushed to the edge of the system? Somewhere there will be logic for deciding if we want to create a EmailContactInfo, PostalContactInfo or BothContactMethods. Shouldn’t this logic be (unit) tested?
For sure the logic for deciding whether to create an EmailContactInfo
, PostalContactInfo
, or BothContactMethods
does need to be implemented somewhere! In fact, this decision-making logic—let's call it the "creation logic"—becomes the entry point where the business rule is enforced. Here's how that ties into the points raised in the book and your question:
Scott Wlaschin advocates for embedding business rules directly in the type system whenever possible. By encoding the constraint (exactly one of the three valid states) in the type system, we make it impossible to create invalid states within the core of our program. This is why runtime checks aren't needed within the domain logic—the compiler ensures that only valid ContactInfo
instances exist.
However, the type system doesn't automatically decide which specific variant of ContactInfo
to create. That logic still resides at the system's edges (e.g., input validation when processing a form, parsing an API request, or constructing a Contact
in a factory function). This is the point where decisions like "Does this user have an email, an address, or both?" are made.
Absolutely, yes. The creation logic itself should be tested because it represents a transformation layer—converting raw inputs (like JSON, user input, or form data) into a properly typed Contact
. This transformation isn't directly guaranteed by the type system, so we use unit tests to ensure it adheres to the business rules.
For example, if you're receiving user input, the logic might look something like this in F#:
let createContactInfo emailOption addressOption =
match emailOption, addressOption with
| Some email, Some address -> EmailAndAddr { Email = email; Address = address }
| Some email, None -> EmailOnly email
| None, Some address -> AddrOnly address
| None, None -> failwith "Invalid input: At least one contact method must be provided."
Here, the type system ensures that the resulting value (ContactInfo
) is valid, but we still need to verify that the logic correctly handles all input combinations. These tests can be as simple as:
[<Fact>]
let ``Creates EmailOnly when only email is provided`` () =
let result = createContactInfo (Some email) None
Assert.Equal(EmailOnly email, result)
[<Fact>]
let ``Fails when no contact method is provided`` () =
Assert.Throws<Exception>(fun () -> createContactInfo None None |> ignore)
It's important to note that this creation logic is separate from the core domain logic. Once a valid ContactInfo
is created, the rest of the domain model doesn't need to worry about invalid states—it can confidently assume that the type constraints have been satisfied.
Testing this logic at the system's edges ensures that:
- Your program enforces the business rules consistently at its boundaries.
- Invalid data (e.g., user input that violates the rules) is caught early.
- Your type-safe domain core remains free from runtime checks and only operates on valid data.