Designing with Types: Making illegal states unrepresentable

In this post, we'll explore one of the most powerful principles in type-driven design: using the type system to "Make illegal states unrepresentable". When we encode our business rules directly into our types, the compiler becomes our first line of defense against bugs.
Let's return to our Contact type from the previous articles. Thanks to our refactoring, it's now well-structured:
class Contact extends Schema.Class<Contact>("Contact")({
name: PersonalName,
emailContactInfo: EmailContactInfo,
postalContactInfo: PostalContactInfo,
}) {}
Now let's say we have a simple business rule: "A contact must have an email or a postal address”. Does our type conform to this rule?
The answer is no. The business rule implies that a contact might have an email address but no postal address, or vice versa. But as it stands, our type requires that a contact must always have both pieces of information.
The naive approach
The answer seems obvious, make the addresses optional:
class Contact extends Schema.Class<Contact>("Contact")({
name: PersonalName,
emailContactInfo: Schema.optionalWith(EmailContactInfo, { as: "Option" }),
postalContactInfo: Schema.optionalWith(PostalContactInfo, { as: "Option" }),
}) {}
But now we've gone too far the other way. In this design, it would be possible for a contact to have neither type of address at all. But the business rule says that at least one piece of information must be present.
What's the solution?
Making illegal states unrepresentable
If we think about the business rule carefully, we realize there are three possibilities:
A contact only has an email address
A contact only has a postal address
A contact has both an email address and a postal address
Once we put it like this, the solution becomes obvious. Use a union type with a case for each possibility. In Effect, we can model this elegantly using Schema.Union:
class EmailOnly extends Schema.TaggedClass<EmailOnly>()("EmailOnly", {
email: EmailContactInfo,
}) {}
class PostOnly extends Schema.TaggedClass<PostOnly>()("PostOnly", {
post: PostalContactInfo,
}) {}
class EmailAndPost extends Schema.TaggedClass<EmailAndPost>()("EmailAndPost", {
email: EmailContactInfo,
post: PostalContactInfo,
}) {}
class Contact extends Schema.Class<Contact>("Contact")({
name: PersonalName,
contactInfo: Schema.Union(EmailOnly, PostOnly, EmailAndPost),
}) {}
This design meets the requirements perfectly. All three cases are explicitly represented, and the fourth possible case (with no email or postal address at all) is not allowed.
How to use
Now let's see how we might use this in practice:
function createEmailOnlyContact(model: {
firstName: string
lastName: string
email: string
}): Effect.Effect<Contact, Brand.Brand.BrandErrors, never> {
return Effect.gen(function* () {
const emailAddress = yield* EmailAddress.either(model.email)
return new Contact({
name: new PersonalName({
firstName: model.firstName,
middleInitial: Option.none(),
lastName: model.lastName,
}),
contactInfo: new EmailOnly({
email: new EmailContactInfo({
emailAddress,
isEmailVerified: false,
}),
}),
})
})
}
function getEmailAddress(contact: Contact): Option.Option<EmailAddress> {
return Match.value(contact.contactInfo).pipe(
Match.tag("EmailOnly", "EmailAndPost", ({ email }) =>
Option.some(email.emailAddress)
),
Match.tag("PostOnly", () => Option.none()),
Match.exhaustive
)
}
function updatePostalAddress(
contact: Contact,
newPostalAddress: PostalContactInfo
): Contact {
const newContactInfo = Match.value(contact.contactInfo).pipe(
Match.tag("EmailOnly", "EmailAndPost",
({ email }) => new EmailAndPost({ email, post: newPostalAddress })
),
Match.tag("PostOnly",
() => new PostOnly({ post: newPostalAddress })
),
Match.exhaustive
)
return new Contact({
name: contact.name,
contactInfo: newContactInfo,
})
}
The Match.exhaustive at the end ensures that if we ever add a new case to our ContactInfo union, the compiler will immediately tell us about every place we need to handle it.
Why bother with these complicated structure?
At this point, you might be saying that we've made things unnecessarily complicated. I would respond with these points:
First, the business logic is complicated. There is no easy way to avoid it. If your code is not this complicated, you're not handling all the cases properly.
Second, if the logic is represented by types, it is automatically self-documenting. You can look at the union cases below and immediately see what the business rule is. You don't have to spend any time trying to analyze any other code:
class Contact extends Schema.Class<Contact>("Contact")({
name: PersonalName,
contactInfo: Schema.Union(EmailOnly, PostOnly, EmailAndPost),
}) {}
Just from reading this type definition, you know: a contact can have email only, postal address only, or both, but never neither.
Third, if the logic is represented by a type, any changes to the business rules will immediately create breaking changes, which is generally a good thing. If you add a fourth case, say PhoneOnly, the compiler will point out every place in your code-base where you need to handle it.
Fourth, as mentioned in previous posts, the Effect adds additional values to TypeScript types by default, including structural equality, immutability, validation and serialization, exhaustive pattern matching, compositions, and pipelines.
Conclusion
The key insight is this: when you find yourself using optional fields with implicit dependencies between them, consider whether a union type would better represent your domain.
Instead of writing code that checks if (contact.email && !contact.postalAddress), you write code that handles each explicit case. The compiler ensures you handle them all, and the types document what states are actually possible.
This is what "Making illegal states unrepresentable" means in practice. It's not about writing more code, it's about writing code where the wrong code simply won't compile.
In the next article, we'll explore how applying this principle can lead to discovering new domain concepts that weren't obvious at first.
This article was inspired by Scott Wlaschin's excellent "Designing with types" series on F# for Fun and Profit, adapted for the TypeScript and Effect ecosystem.



