> Amin Khansari's Notes_

Designing with Types: Conclusion

A before-and-after look at the Contact model, what its types now prevent, and where type-driven design is worth the extra code.

Person sealing a cardboard box with packing tape

We started this series with a Contact type that looked reasonable. It had familiar fields, no strange abstractions, and very little code:

type Contact = {
    firstName: string
    middleInitial: string
    lastName: string
    emailAddress: string
    isEmailVerified: boolean
    address1: string
    address2: string
    city: string
    state: string
    zip: string
    isAddressValid: boolean
}

The type was easy to read because it said almost nothing.

It did not say whether a name could be empty, whether a zip code had been checked, or whether a contact needed an email address, a postal address, or both. The two booleans could disagree with the data beside them. Every caller had to remember the missing rules.

The point of this series was to move those rules into the model.

The final model

The scalar types now carry meaning and runtime constraints:

const MiddleInitial = constrainedSingleLine("MiddleInitial", 1)
const AddressLine = constrainedSingleLine("AddressLine", 100)
const City = constrainedSingleLine("City", 100)

const EmailAddressSchema = Schema.String.pipe(
    Schema.fromBrand("EmailAddress", EmailAddress),
)
const StateCodeSchema = Schema.String.pipe(
    Schema.fromBrand("StateCode", StateCode),
)
const ZipCodeSchema = Schema.String.pipe(
    Schema.fromBrand("ZipCode", ZipCode),
)

Names and addresses compose those smaller values:

class PersonalName extends Schema.Class<PersonalName>("PersonalName")({
    firstName: FirstName,
    middleInitial: Schema.OptionFromOptionalKey(MiddleInitial),
    lastName: LastName,
}) {}

class PostalAddress extends Schema.Class<PostalAddress>("PostalAddress")({
    address1: AddressLine,
    address2: Schema.OptionFromOptionalKey(AddressLine),
    city: City,
    state: StateCodeSchema,
    zip: ZipCodeSchema,
}) {}

The optional fields are no longer empty strings with a secret meaning. They decode to Option, so code has to handle absence.

As we saw in Making state explicit, validation flags need more work. A boolean lets us claim that an email is verified without recording when verification happened. Tagged states make the difference explicit:

class UnverifiedEmail extends Schema.TaggedClass<UnverifiedEmail>()(
    "UnverifiedEmail",
    { address: EmailAddressSchema },
) {}

class VerifiedEmail extends Schema.TaggedClass<VerifiedEmail>()(
    "VerifiedEmail",
    {
        address: EmailAddressSchema,
        verifiedAt: SupportedTimestamp,
    },
) {}

const EmailContactInfo = Schema.Union([
    UnverifiedEmail,
    VerifiedEmail,
])

A verified email must carry its verification timestamp. An unverified email cannot accidentally acquire one. A function that sends password-reset messages can require VerifiedEmail, and the compiler rejects the unverified state.

The state types define what can exist. Transition functions define how those values may change. verifyEmail accepts an UnverifiedEmail and returns a VerifiedEmail. Changing the address moves either state to UnverifiedEmail, while verifying an existing VerifiedEmail through the dynamic event handler returns InvalidEmailTransition.

The postal address follows the same rule:

class UnvalidatedPostal extends Schema.TaggedClass<UnvalidatedPostal>()(
    "UnvalidatedPostal",
    { address: PostalAddress },
) {}

class ValidatedPostal extends Schema.TaggedClass<ValidatedPostal>()(
    "ValidatedPostal",
    {
        address: PostalAddress,
        validatedAt: SupportedTimestamp,
    },
) {}

const PostalContactInfo = Schema.Union([
    UnvalidatedPostal,
    ValidatedPostal,
])

Finally, the contact-method rule has one case for each allowed combination:

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]),
}) {}

There is no case for a contact with neither method. Code cannot construct that state through the schema.

One decoder for the boundary

The final model is also a runtime schema:

const decodeContact = Schema.decodeUnknownResult(Contact)

One call checks the whole input. It normalizes constrained strings, validates email and postal fields, parses timestamps into UTC, and checks every union tag. Success returns the domain model. Failure returns a structured SchemaError.

Events need the same boundary check. An event schema rejects malformed input before it reaches the domain. The transition handler then decides whether that valid event is allowed for the current state. Schema errors and invalid transitions are different failures, and callers can handle them differently.

This does not mean validation should happen everywhere. Decode at the boundary, then let domain functions accept Contact, VerifiedEmail, or another precise type. If an operation can break an invariant, its return type must admit failure and validate the result again.

Yes, it is more code

The final model is much longer than the original object. That is not automatically a win.

Some of the length comes from TypeScript lacking F# features such as discriminated unions and units of measure. Effect closes part of that gap, but the syntax remains heavier.

Most of the added code, however, names a decision that the first model ignored. How long can a name be? Can a contact have no contact method? What proves that an email is verified? Which timestamps can another system store? Those decisions existed before we wrote the types. They were hiding in conditionals, comments, and database errors.

For a temporary search query or a loop counter, this work would be wasteful. For data that drives business decisions, survives for years, or crosses service boundaries, I want the rules in one place.

What the model now catches

The final model catches mistakes at compile time, at data boundaries, and during transitions:

  • A zip code cannot be passed as an email address.
  • A blank or overlong name cannot become a PersonalName.
  • A contact cannot have neither email nor postal information.
  • Code handling contact methods must account for every allowed case.
  • An unverified email cannot be passed to a function that requires VerifiedEmail.
  • A known invalid transition does not compile through a state-specific function.
  • A dynamic invalid transition returns InvalidEmailTransition instead of being ignored.
  • Arithmetic and string composition cannot keep a brand without another check.

This reduces the number of facts a developer must remember. The compiler, schemas, and transition handlers handle the mechanical parts every time.

What the model cannot prevent

The model only enforces the rules we give it. A perfectly typed model can still encode the wrong business requirement.

TypeScript also has escape hatches. A careless as cast can bypass a brand. External data remains untrusted until a schema decodes it. Even a valid model can be used by a function with incorrect behavior.

Tests and reviews still matter. Conversations with domain experts matter most, because no type checker can tell us whether a maximum of 99 items is the right rule for the business.

Effect does not discover the model for us. It gives us practical tools once we understand what the model should say: Schema for boundaries, Option for absence, Result for expected failure, tagged classes for explicit states, and Match for exhaustive state and event handling.

A useful threshold

I introduce a domain type when at least one concrete problem appears:

  • Two values share a JavaScript representation but must not be mixed.
  • A value has constraints that several callers need to enforce.
  • Fields must change together to remain consistent.
  • A value moves through lifecycle states that allow different operations or data.
  • An operation can produce a value outside the allowed range.
  • Optional fields depend on one another.

Start with places where a bad value costs money, loses data, or creates support work. The goal is not to maximize the number of types. The goal is to make important rules hard to violate by accident.

Designing with types means treating the type system as part of the design process, not as a final annotation pass after the decisions have already been buried in code.

Kudos to Domain Modeling Made Functional

Kudos to Scott Wlaschin and Domain Modeling Made Functional. This series would not exist without his work. The examples use F#, but the design lessons transfer well to TypeScript and Effect.

SERIES: Continue Reading

SEARCH POSTS

START TYPING TO SEARCH_