Selective functional patterns
Functional patterns solve specific modeling defects. They are not a default
architecture or an extra layer to place around working project types.
Start with the owning boundary
Follow this order and stop at the first step that satisfies the task:
- Reuse the owning boundary's existing schema, generated type, library type, or
public contract.
- Infer types with the installed tool's supported utilities.
- Compose, refine, or extend the canonical schema or type.
- Improve the representation at its owner when the task exposes a real gap.
- Introduce one minimal custom type only when the hard gate below is satisfied.
Do not continue down the list once the existing representation can express the
requirement safely.
Canonical owners
- TypeBox schemas own their request and response shapes. Use
Static<typeof Schema> and TypeBox composition rather than handwritten mirrors.
- Drizzle tables own their persistence shapes. Use
typeof table.$inferSelect,
typeof table.$inferInsert, or the inference convention already used by the
package.
- DynamoDB Toolbox entities own their item shapes. Use
InputItem<typeof Entity>,
FormattedItem<typeof Entity>, and the entity's item schema.
- Effect Schema and other installed schema libraries own their inferred types,
refinements, parse errors, and tagged schemas.
- Installed functional libraries own their
Option, Result, Either, and
effect types, constructors, and matching conventions.
- Existing nullable, throwing, promise-based, generated, and plain TypeScript
contracts remain canonical when the project already uses them.
Keep responsibilities with their owner
- Domain models own construction, invariant-preserving transformations, and
typed decoding. Established models may expose
fromRequest, Drizzle
fromRow/toRow, or DynamoDB fromItem/toItem methods.
- Repositories own I/O: queries, transactions, tenant and optimistic-write
predicates, and database or driver error classification.
- Services own use-case orchestration, including sequencing repositories and
deciding whether to retry a failed operation.
- Routes own HTTP validation and response serialization.
Keep pure invariant functions pure. Do not impose an import-purity rule on an
entire domain/ directory. A domain model may import request, Drizzle row, or
DynamoDB Toolbox item types when type-only imports prevent duplicate mirror
types. It may also construct an Effect value to represent typed success,
failure, or absence when doing so does not execute the Effect or reach external
state.
Domain models must not import Fastify handlers, database clients, DynamoDB
commands, SQL builders, Layers, runtime execution, configuration, or environment
access. Those dependencies belong at the application or repository boundary.
Do not add a middle model
Do not place a handwritten tagged, branded, or class-based model between an API
schema and a persistence schema merely to rename fields or repeat validation.
When no domain model is justified, convert at the active boundary without
inventing one.
Different boundaries may legitimately have different types. A TypeBox request,
a Drizzle row, and a DynamoDB item do not need a third universal domain type to
connect them. Reuse an existing behavior-rich domain entity when the package
already has one, but do not create an entity class solely to wrap a row or item.
Hard gate for a custom type
Add a custom tagged union, brand, opaque type, Option, or Result only when all
of these conditions hold:
- The changed code contains a concrete defect, invalid state, or unsafe
interchange that the type should prevent.
- The owning schema, installed library, and current project types cannot express
the distinction through composition, refinement, literals, constraints, or
their native error model.
- The new type prevents the defect instead of restating validation or giving an
existing value another name.
- One boundary can own construction and validation consistently.
- The type does not introduce routine adapters, duplicate serializers, or a
second representation across callers, persistence, and tests.
If any condition fails, keep the existing representation.
Choose the smallest representation
- Use a literal union such as
"pending" | "settled" before wrapping each value
in a tagged object.
- Use a discriminated union when variants carry different data or when it removes
a demonstrated invalid combination of fields.
- Use a brand only when values with the same primitive representation remain easy
to confuse after applying the existing schema and library tools.
- Use the established nullable or failure contract before considering
Option or
Result.
- Keep runtime validation in the owning schema. A custom compile-time type must
not replace boundary validation.
Validation, nullability, recoverable failure, identifiers, and domain terminology
do not by themselves justify a custom type.
Working method
- Inspect imports, package dependencies, schemas, generated types, public
contracts, and immediate callers.
- Name the specific unsafe state or operation required by the task.
- Reuse or extend the highest existing owner that can prevent it.
- Put construction and persistence transformations on an established domain
model; otherwise keep conversion at the active boundary.
- Test the changed behavior using the package's existing test utilities.
References
Read only the reference needed for the active problem:
Review checklist
- The owning schema, library, or project type was identified first.
- Schema-derived and generated types remain canonical at their boundaries.
- Domain, repository, service, and route responsibilities remain separate.
- No unnecessary middle model or generic functional helper was added.
- Every new custom type passes the hard gate.
- The change fixes the named problem without spreading a second representation.
1---2name: typescript-functional-patterns3description: Selective use of functional TypeScript patterns and domain-model responsibilities. Use when a task involves a state machine, discriminated union, Option/Result/Either/Effect API, branded or opaque type, typed domain decoding, or deciding which layer should own construction and transformation. Do not use to add a domain model when existing schemas and types already cover the requirement.4---56# Selective functional patterns78Functional patterns solve specific modeling defects. They are not a default9architecture or an extra layer to place around working project types.1011## Start with the owning boundary1213Follow this order and stop at the first step that satisfies the task:14151. Reuse the owning boundary's existing schema, generated type, library type, or16 public contract.172. Infer types with the installed tool's supported utilities.183. Compose, refine, or extend the canonical schema or type.194. Improve the representation at its owner when the task exposes a real gap.205. Introduce one minimal custom type only when the hard gate below is satisfied.2122Do not continue down the list once the existing representation can express the23requirement safely.2425### Canonical owners2627- TypeBox schemas own their request and response shapes. Use28 `Static<typeof Schema>` and TypeBox composition rather than handwritten mirrors.29- Drizzle tables own their persistence shapes. Use `typeof table.$inferSelect`,30 `typeof table.$inferInsert`, or the inference convention already used by the31 package.32- DynamoDB Toolbox entities own their item shapes. Use `InputItem<typeof Entity>`,33 `FormattedItem<typeof Entity>`, and the entity's item schema.34- Effect Schema and other installed schema libraries own their inferred types,35 refinements, parse errors, and tagged schemas.36- Installed functional libraries own their `Option`, `Result`, `Either`, and37 effect types, constructors, and matching conventions.38- Existing nullable, throwing, promise-based, generated, and plain TypeScript39 contracts remain canonical when the project already uses them.4041## Keep responsibilities with their owner4243- Domain models own construction, invariant-preserving transformations, and44 typed decoding. Established models may expose `fromRequest`, Drizzle45 `fromRow`/`toRow`, or DynamoDB `fromItem`/`toItem` methods.46- Repositories own I/O: queries, transactions, tenant and optimistic-write47 predicates, and database or driver error classification.48- Services own use-case orchestration, including sequencing repositories and49 deciding whether to retry a failed operation.50- Routes own HTTP validation and response serialization.5152Keep pure invariant functions pure. Do not impose an import-purity rule on an53entire `domain/` directory. A domain model may import request, Drizzle row, or54DynamoDB Toolbox item types when type-only imports prevent duplicate mirror55types. It may also construct an Effect value to represent typed success,56failure, or absence when doing so does not execute the Effect or reach external57state.5859Domain models must not import Fastify handlers, database clients, DynamoDB60commands, SQL builders, Layers, runtime execution, configuration, or environment61access. Those dependencies belong at the application or repository boundary.6263## Do not add a middle model6465Do not place a handwritten tagged, branded, or class-based model between an API66schema and a persistence schema merely to rename fields or repeat validation.67When no domain model is justified, convert at the active boundary without68inventing one.6970Different boundaries may legitimately have different types. A TypeBox request,71a Drizzle row, and a DynamoDB item do not need a third universal domain type to72connect them. Reuse an existing behavior-rich domain entity when the package73already has one, but do not create an entity class solely to wrap a row or item.7475## Hard gate for a custom type7677Add a custom tagged union, brand, opaque type, `Option`, or `Result` only when all78of these conditions hold:79801. The changed code contains a concrete defect, invalid state, or unsafe81 interchange that the type should prevent.822. The owning schema, installed library, and current project types cannot express83 the distinction through composition, refinement, literals, constraints, or84 their native error model.853. The new type prevents the defect instead of restating validation or giving an86 existing value another name.874. One boundary can own construction and validation consistently.885. The type does not introduce routine adapters, duplicate serializers, or a89 second representation across callers, persistence, and tests.9091If any condition fails, keep the existing representation.9293## Choose the smallest representation9495- Use a literal union such as `"pending" | "settled"` before wrapping each value96 in a tagged object.97- Use a discriminated union when variants carry different data or when it removes98 a demonstrated invalid combination of fields.99- Use a brand only when values with the same primitive representation remain easy100 to confuse after applying the existing schema and library tools.101- Use the established nullable or failure contract before considering `Option` or102 `Result`.103- Keep runtime validation in the owning schema. A custom compile-time type must104 not replace boundary validation.105106Validation, nullability, recoverable failure, identifiers, and domain terminology107do not by themselves justify a custom type.108109## Working method1101111. Inspect imports, package dependencies, schemas, generated types, public112 contracts, and immediate callers.1132. Name the specific unsafe state or operation required by the task.1143. Reuse or extend the highest existing owner that can prevent it.1154. Put construction and persistence transformations on an established domain116 model; otherwise keep conversion at the active boundary.1175. Test the changed behavior using the package's existing test utilities.118119## References120121Read only the reference needed for the active problem:122123- [ADTs](./references/adts.md) for deciding between literal and discriminated124 unions125- [Option and Result](./references/option-result.md) for absence and failure126 contracts127- [Branded types](./references/branded-types.md) for nominal distinctions and128 units129- [Migration guide](./references/migration-guide.md) for a focused change to an130 existing codebase131132## Review checklist133134- The owning schema, library, or project type was identified first.135- Schema-derived and generated types remain canonical at their boundaries.136- Domain, repository, service, and route responsibilities remain separate.137- No unnecessary middle model or generic functional helper was added.138- Every new custom type passes the hard gate.139- The change fixes the named problem without spreading a second representation.