Skip to content

FrontendTypeBoundary

Paulo Gomes da Cruz Junior edited this page Oct 20, 2026 · 1 revision

Pattern: Frontend Type Boundary

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

Overview

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).

Purpose

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() and optional() 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

Canonical Type Home

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.

Zod Boundary Rulebook

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:

  1. One schema per wire shape.
  2. Export only schemas that are parsed or composed. Types are always derived via z.infer.
  3. No banner comments above schema declarations.
  4. Parse once, at the queryFn/http layer (src/config/react-query/hooks.ts). Never parse in components.
  5. Transforms exist only for wire-to-app normalization, never for shape repair.

Java DTO Is the Source of Truth for Nullability

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().

Query Hook Gotchas

Pin Generics Around the Parse

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>

Page Rows Need an Id

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.

Fixtures Are the Early-Warning System

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.

Move Protocol

One atomic commit per move, with the message shape refactor(types): move X into shared @/types module:

  1. git mv the types file into frontend/src/types/ with the .types.ts suffix.
  2. Repoint consumers. Watch both '@/types/x' and '@/types/x.ts' extension variants, plus relative ./x.types imports.
  3. Add the barrel line to frontend/src/types/index.ts.
  4. Run eslint --fix on the touched files. Pipe paths through xargs — see Frontend Verification and Tooling.
  5. Typecheck both configs: tsconfig.app.json and tsconfig.test.json.
  6. Run vitest, then e2e for the affected suites.
  7. Commit.

Shared building blocks already composed inside @/types: codeDescriptionSchema (search.types), uploadAuditSchema (audit.types), and pageTypeSchema/pageableResponseSchema (pagination.types). Reuse them instead of redeclaring.

Anti-Pattern (What to Avoid)

// ❌ 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 commit

Why 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
  • as casts silence the fixture/contract failures that are the early-warning system
  • Oversized move commits are unreviewable and hard to revert

When to Apply

  • 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/

When NOT to Apply

  • 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

Detection

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

Related Patterns

See Also

  • [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 IdentifiableContent rows

LESSONS_HINT

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.

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally