Repository navigation
FrontendTypeBoundary
Type: Pattern Domain: Frontend — Types / Data Boundary Applies to: React 19 + TypeScript + Zod 4 + TanStack Query Enforced by: react-specialist Last updated: 2026-10-19 Status: Current
The Frontend Type Boundary pattern defines where shared frontend types live and how they relate to the backend wire format. It covers the canonical type home (@/types), the Zod 4 schema rulebook for wire shapes, the mapping from Java DTO nullability to Zod modifiers, query-hook generics that interact with a parse-returning queryFn, and the protocol for moving a type into the shared module. Distilled from the frontend types consolidation on branch chore/tech-debt-types (commits 2da3794d through 0c3f4fcc, 2026-10).
Without this pattern, developers would:
- Keep adding shared types to
services/or component folders, splitting the import surface - Hand-write DTO types that drift from the Java contract instead of deriving them from a schema
- Guess between
nullable()andoptional()and get runtime surprises on absent versus null keys - Fight TanStack Query generics that conflict with a parse-returning queryFn
- Move types in sprawling commits that leave consumers or fixtures behind
| Surface | Role |
|---|---|
frontend/src/types/*.types.ts |
Shared types and Zod 4 wire schemas, one module per domain |
frontend/src/types/index.ts |
Barrel of export type * lines — the single stable import surface (@/types) |
frontend/src/services/types.ts |
Back-compat re-export barrel until consumers migrate; do not add new shared types here |
frontend/src/components/Form/TableResource/utils.ts |
Runtime helpers stay component-local |
@/types holds pure types and wire schemas only. TableResource's runtime helpers stayed in the component folder by design. The former pass-through shim types/PageableResponse.types.ts was deleted during the consolidation.
Every wire shape (API payload, URL parameter, form value) moved into @/types gets a Zod 4 schema, and the exported type is derived from it. Internal type utilities stay plain types.
| Zod 3 habit | Zod 4 replacement |
|---|---|
z.string().datetime() |
z.iso.datetime() |
z.string().date() |
z.iso.date() |
z.string().email() |
z.email() |
z.string().uuid() |
z.uuid() |
z.string().url() |
z.url() |
z.nativeEnum(X) |
z.enum(...) |
.strict() / .passthrough()
|
z.strictObject() / z.looseObject()
|
.merge(other) |
.extend(other.shape) |
z.ZodTypeAny |
z.ZodType |
{ message: '...' } |
{ error: '...' } |
Rules:
- One schema per wire shape.
- Export only schemas that are parsed or composed. Types are always derived via
z.infer. - No banner comments above schema declarations.
- Parse once, at the queryFn/http layer (
src/config/react-query/hooks.ts). Never parse in components. - Transforms exist only for wire-to-app normalization, never for shape repair.
| Java field | Wire behavior | Zod modifier |
|---|---|---|
No @JsonInclude
|
Key always serialized; null possible |
.nullable() |
@JsonInclude(NON_NULL) |
Key absent when null |
.optional() / .nullish()
|
Java primitive (boolean) |
Always present | plain, no modifier |
| Semantic PK (id never null in practice) | Present | may stay required even when the Java wrapper is nullable — document the choice |
Date typing follows the Java field type:
| Java type | Zod schema | Wire shape |
|---|---|---|
LocalDate |
z.iso.date() |
2026-10-19 |
LocalDateTime |
z.iso.datetime({ local: true }) |
No offset suffix; represents UTC |
Instant / UTC OffsetDateTime
|
z.iso.datetime() |
Z suffix required |
Ids and counts use z.int().
An unbounded <TData> used in every UseQueryOptions slot conflicts with a queryFn that returns a parsed result, because the queryFn's concrete return type and the slot-1 TData must agree. Pin the concrete type in slot 1 and keep TData only for the select position:
UseQueryOptions<ConcreteDto, Error, TData, QueryKey>PageableResponse<T> rows are IdentifiableContent<T> ({ id: string | number } & T), which TableResource requires. When the wire row lacks id, derive one in a transform at the queryFn instead of casting:
// src/config/react-query/hooks.ts
pageableResponseSchema(myForestClientSchema)
.transform((page) => ({
...page,
content: page.content.map((row) => ({ ...row, id: row.client.code ?? '' })),
}))
.parse(await API.forestclient.searchForestClients(...))An as cast hides exactly the drift the schema parse just caught.
Once a boundary parse exists, stale test fixtures and e2e stubs fail loudly: missing fields, missing Z suffix on Instants, missing type discriminators, missing required wire fields. Treat fixture failures after parse wiring as contract drift, not test noise, and fix the fixture.
Classes already hit during the consolidation: secondaryTimberMarks-era expanded payloads, list rows missing secondaryEntry, and tableData missing the SPECIES_COMPOSITION discriminator.
One atomic commit per move, with the message shape refactor(types): move X into shared @/types module:
-
git mvthe types file intofrontend/src/types/with the.types.tssuffix. - Repoint consumers. Watch both
'@/types/x'and'@/types/x.ts'extension variants, plus relative./x.typesimports. - Add the barrel line to
frontend/src/types/index.ts. - Run
eslint --fixon the touched files. Pipe paths throughxargs— see Frontend Verification and Tooling. - Typecheck both configs:
tsconfig.app.jsonandtsconfig.test.json. - Run
vitest, then e2e for the affected suites. - Commit.
Shared building blocks already composed inside @/types: codeDescriptionSchema (search.types), uploadAuditSchema (audit.types), and pageTypeSchema/pageableResponseSchema (pagination.types). Reuse them instead of redeclaring.
// ❌ Adding a new shared type to services/ or a component's types.ts
// ❌ Hand-writing a DTO type instead of deriving it with z.infer
// ❌ z.string().datetime() — Zod 3 style, invalid under Zod 4
// ❌ .nullable() where the backend omits the key (@JsonInclude(NON_NULL)), or the reverse
// ❌ Parsing in a component instead of the queryFn layer
// ❌ `as SomeDto` casts to satisfy a missing id
// ❌ Banner comments above schema declarations
// ❌ One commit moving five types — one move per commitWhy this is wrong:
- Splitting the import surface re-creates the drift the consolidation removed
- Hand-written DTOs rot independently of the Java contract
- Zod 3 APIs do not exist in Zod 4 and mask nullability mistakes at the boundary
- Component-layer parsing duplicates the single parse point and hides contract changes
-
ascasts silence the fixture/contract failures that are the early-warning system - Oversized move commits are unreviewable and hard to revert
- Adding or moving any shared DTO, wire schema, or type utility
- Wiring a queryFn whose endpoint shape you now trust
- Reviewing a PR that touches
frontend/src/types/
- Component prop types — they stay co-located in the component's
types.ts - Runtime helpers — they stay component-local (for example
TableResource/utils.ts) - One-off local unions used by a single component that never cross a boundary
| Check | Method |
|---|---|
New shared type outside @/types
|
Grep new *.types.ts files under src/services/ — only back-compat re-exports are allowed there |
| Zod 3 API usage | Grep z.string().datetime|z.nativeEnum|ZodTypeAny|\.merge\( in src/types/
|
| Component-layer parse | Grep .parse( under src/components/ — parse belongs in config/react-query/hooks.ts
|
| Cast to a DTO | Grep as PageableResponse|as .*Dto in hooks and services |
| Missing id derivation | Each page-producing queryFn must satisfy IdentifiableContent — check the content: mapping |
- Frontend Verification and Tooling — the typecheck, ESLint, and e2e gates the move protocol runs
- [Frontend Structure Guidelines](Frontend Structure Guidelines) — Domain Model Organization rules
- [Frontend Structure](Frontend Structure) — directory layout and the resolved shared-types gap
-
TableResource — the table component that requires
IdentifiableContentrows
This page was generated from the types consolidation on chore/tech-debt-types (2026-10-19). When the backend changes a field's nullability or serialization, update the schema and its fixtures in the same change — the parse boundary turns the mismatch into a test failure instead of a runtime surprise.
This wiki documents NR Waste Plus for users and developers. Verify security and deployment guidance against the current application configuration before applying it elsewhere.
- Report an issue or ask a question in the GitHub repository.
- Follow the Contributing Guidelines to propose an update.
- Read the Code of Conduct.
Collection indexes support human browsing and machine retrieval. Keep both current when adding or moving wiki pages.
NR Waste Plus Wiki | GitHub Repository