Designing with Types: Making state explicit
How to model lifecycle rules as tagged states and events with Effect, making every transition and failure explicit.

In the previous article, we used a tagged union to describe the allowed combinations of contact methods. A contact can have email, post, or both, but never neither.
Those cases describe the shape of a contact. Some domain rules also describe how a value changes over time.
Consider the email information from our original model:
class EmailContactInfo extends Schema.Class<EmailContactInfo>(
"EmailContactInfo",
)({
emailAddress: EmailAddressSchema,
isEmailVerified: Schema.Boolean,
}) {}
The boolean suggests two states, but leaves their behavior implicit. A password reset should only go to a verified address. A verification message should only go to an unverified address. If the address changes, it must become unverified again.
Every function that receives EmailContactInfo has to remember those rules. The type does not help.
A union is not always a state machine
A tagged union gives us a finite set of cases. It becomes a state machine when events can move a value between those cases.
A small state machine has three parts:
- States describe the value as it exists now.
- Events describe something that happened.
- Transitions decide the next state for each state and event pair.
For email verification, the transition table is small enough to read at a glance:
| Before | Event | After |
|---|---|---|
UnverifiedEmail | VerifyEmail(verifiedAt) | VerifiedEmail with the timestamp |
UnverifiedEmail | ChangeEmailAddress(address) | UnverifiedEmail with the new address |
VerifiedEmail | VerifyEmail(verifiedAt) | InvalidEmailTransition |
VerifiedEmail | ChangeEmailAddress(address) | UnverifiedEmail with the new address |
Writing this table exposes a decision that the boolean hid. Verifying an already verified address is not silently ignored. It is an explicit failure. Changing either kind of address produces an unverified address because verification belonged to the old address.
Give each state its own data
An unverified address only needs the address itself. A verified address also needs evidence of when verification happened:
const VerificationTimestamp = Schema.DateTimeUtcFromString
type VerificationTimestamp = typeof VerificationTimestamp.Type
class UnverifiedEmail extends Schema.TaggedClass<UnverifiedEmail>()(
"UnverifiedEmail",
{ address: EmailAddressSchema },
) {}
class VerifiedEmail extends Schema.TaggedClass<VerifiedEmail>()(
"VerifiedEmail",
{
address: EmailAddressSchema,
verifiedAt: VerificationTimestamp,
},
) {}
const EmailState = Schema.Union([UnverifiedEmail, VerifiedEmail])
type EmailState = typeof EmailState.Type
There is no boolean to keep in sync with verifiedAt. An UnverifiedEmail cannot carry a verification timestamp, and a VerifiedEmail cannot exist without one.
VerificationTimestamp parses an ISO timestamp into Effect’s UTC DateTime type. A later article will add domain-specific limits to dates. Here, its purpose is to show that each state can carry different data.
Use precise functions when the state is known
When trusted domain code knows the current state, the transition function can ask for that exact type:
function verifyEmail(
email: UnverifiedEmail,
verifiedAt: VerificationTimestamp,
): VerifiedEmail {
return new VerifiedEmail({
address: email.address,
verifiedAt,
})
}
function changeEmailAddress(address: EmailAddress): UnverifiedEmail {
return new UnverifiedEmail({ address })
}
Unlike verification, changing an address has the same outcome from either state. The helper only needs the new address, and its return type says every new address starts unverified.
State-specific behavior can be just as precise:
function sendVerificationEmail(email: UnverifiedEmail): void {
// Send a verification link to email.address.
}
function sendPasswordReset(email: VerifiedEmail): void {
// Send a password reset link to email.address.
}
Passing an UnverifiedEmail to sendPasswordReset does not compile. Calling verifyEmail with a VerifiedEmail does not compile either. No runtime check is needed when the caller already has a precise state.
Model events for dynamic callers
An HTTP handler, message consumer, or UI event often knows only that it has some EmailState. It needs one entry point that handles every event from every state.
First, make the events explicit:
class VerifyEmail extends Schema.TaggedClass<VerifyEmail>()("VerifyEmail", {
verifiedAt: VerificationTimestamp,
}) {}
class ChangeEmailAddress extends Schema.TaggedClass<ChangeEmailAddress>()(
"ChangeEmailAddress",
{ address: EmailAddressSchema },
) {}
const EmailEvent = Schema.Union([VerifyEmail, ChangeEmailAddress])
type EmailEvent = typeof EmailEvent.Type
An invalid transition is expected domain data, so give it a type instead of throwing or returning a boolean:
class InvalidEmailTransition extends Schema.TaggedError<InvalidEmailTransition>()(
"InvalidEmailTransition",
{
state: Schema.Literals(["UnverifiedEmail", "VerifiedEmail"]),
event: Schema.Literals(["VerifyEmail", "ChangeEmailAddress"]),
},
) {}
The event handler accepts the whole state machine. Nested exhaustive matches implement the transition table:
function transitionEmail(
state: EmailState,
event: EmailEvent,
): Result.Result<EmailState, InvalidEmailTransition> {
return Match.value(state).pipe(
Match.tag("UnverifiedEmail", (email) =>
Match.value(event).pipe(
Match.tag("VerifyEmail", ({ verifiedAt }) =>
Result.succeed(verifyEmail(email, verifiedAt)),
),
Match.tag("ChangeEmailAddress", ({ address }) =>
Result.succeed(changeEmailAddress(address)),
),
Match.exhaustive,
),
),
Match.tag("VerifiedEmail", (email) =>
Match.value(event).pipe(
Match.tag("VerifyEmail", (event) =>
Result.fail(
new InvalidEmailTransition({
state: email._tag,
event: event._tag,
}),
),
),
Match.tag("ChangeEmailAddress", ({ address }) =>
Result.succeed(changeEmailAddress(address)),
),
Match.exhaustive,
),
),
Match.exhaustive,
)
}
The outer match covers every state. Each inner match covers every event for that state. Adding a third state or event makes this function stop compiling until its new cases are handled.
The narrow helpers and the whole-machine handler solve different problems. Precise helpers prevent mistakes when the current state is known. The handler deals with dynamic input and reports invalid state and event combinations through Result.
Invalid input and invalid transitions are different
Because the states and events are schemas, they can decode unknown input at an application boundary:
const decodeEmailState = Schema.decodeUnknownResult(EmailState)
const decodeEmailEvent = Schema.decodeUnknownResult(EmailEvent)
These decoders reject malformed data, such as a VerifyEmail event with an invalid timestamp or an unknown tag.
A valid VerifyEmail event applied to an existing VerifiedEmail is different. Both values are well formed, but the business rule rejects their combination. That failure comes from transitionEmail as InvalidEmailTransition.
Keeping those failures separate gives callers useful information. A malformed request can produce a boundary validation response. An invalid transition can produce a domain-specific response or be recorded for later analysis.
Flags, enums, and groups of optional fields
Boolean flags are one clue that a state machine may be hiding in a model. Status enums can have the same problem. This type says an order has a status, but it does not say which data belongs to each status:
type Order = {
status: "New" | "Paid" | "Shipped"
paidAt?: string
shippedAt?: string
trackingNumber?: string
}
Nothing prevents a New order with a tracking number or a Shipped order with no shipping data. Separate NewOrder, PaidOrder, and ShippedOrder states can attach the right data to each stage. Transition functions can then define that payment precedes shipping.
The same warning applies when several optional fields appear and become required together. They often describe unnamed states.
When not to build a state machine
Explicit states add types, events, and transition code. That cost is useful when behavior changes by state and invalid transitions matter to the domain. It is noise when a status is only display metadata or all operations behave the same way in every state.
Before introducing a state machine, ask concrete questions:
- Does each state allow different operations?
- Do events move values between states inside this application?
- Does each state own different data?
- Are the transition rules stable enough to encode in code?
If the answer is mostly no, a field may be enough.
Conclusion
Making state explicit turns scattered conditionals into a model that can be read and checked. Tagged classes name each state and event. Precise functions reject known mistakes at compile time. An exhaustive handler covers dynamic cases and returns typed failures for transitions the domain does not allow.
In the next article, we’ll look more closely at strings and put their length and formatting rules into the types that represent them.