> Amin Khansari's Notes_

Designing with Types: Making state explicit

How to model lifecycle rules as tagged states and events with Effect, making every transition and failure explicit.

Row of green industrial machines with control levers in different positions

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:

BeforeEventAfter
UnverifiedEmailVerifyEmail(verifiedAt)VerifiedEmail with the timestamp
UnverifiedEmailChangeEmailAddress(address)UnverifiedEmail with the new address
VerifiedEmailVerifyEmail(verifiedAt)InvalidEmailTransition
VerifiedEmailChangeEmailAddress(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.

SERIES: Continue Reading

SEARCH POSTS

START TYPING TO SEARCH_