Back to postsEngineering

Runtime validation beyond type assertions

An illustration of observing, recovering, or rejecting external values at different boundaries.

An illustration of the topic, rather than an actual product screenshot or exact architecture diagram.

Reading through as assertions showed me that they existed for different reasons. Some reasserted a value whose type was already known because a library callback was loosely typed. Others trusted values arriving from an API or URL without knowing what they contained.

I initially planned to put Zod wherever an assertion was needed. The first group was better served by connecting the existing types. The second needed schemas, but a mismatch could not always stop the screen. Existing responses began with logging, URLs recovered usable conditions, and forms showed fields to correct.

Not every assertion became Zod

A data-table callback was one such case. Its first argument was broadly typed, but the second, record, retained the data source’s row type.

typescript
// 느슨한 셀 값을 다시 단언하지 않습니다.
render: (_, record) => STATUS_LABEL[record.status]

Reading the already typed record.status made more sense than validating the cell again. Shared components missing generics gained them; values that could genuinely be null received corrected types and rendering branches.

An API response or URL can differ from its declaration even when the types connect correctly. Those values could not be handled by repairing type flow alone.

  • Server API responses
  • URL parameters edited by users or retained in old links
  • User-entered form values
  • Decoded tokens, stream events, and external callback values

A generic such as jwtDecode<Claims>(token) does not verify that its result is Claims. That is a runtime boundary for Zod.

typescript
const claimsSchema = z.object({ role: z.enum(ROLES) });
 
try {
  const { role } = claimsSchema.parse(jwtDecode(token));
  return ALLOWED_ROLES.includes(role);
} catch {
  return false;
}

For a token check that already returns false on invalid input, a parse failure can follow that path. Rejecting every mismatch across existing API responses required more care.

Log mismatched fields in existing API responses first

I added a safeParse check around API responses. Despite the name parseApiResponse, the function returns the original response rather than the parsed result.

tsx
export function parseApiResponse<T>(schema: z.ZodType, response: AxiosResponse<T>): T {
  const result = schema.safeParse(response.data);
 
  if (!result.success) {
    reportOnce({
      code: 'RESPONSE_SCHEMA_MISMATCH',
      request: `${response.config.method} ${response.config.url}`,
      issues: result.error.issues,
    });
  }
 
  return response.data;
}

A mismatch records the fields that differ. Both success and failure return response.data, so recording a warning does not change the value consumed by the screen.

I was adding validation to working screens and could not assume the new schemas captured every field. Zod objects strip undeclared keys. Switching immediately to result.data could remove a field missing from the frontend schema and break a screen that still used it.

I also did not want one auxiliary field mismatch among 50 list items to remove the entire list. Policies such as field defaults may be appropriate later, but were not settled.

Logs retain only information needed to investigate.

  • HTTP method and request path from Axios config
  • Screen path and application
  • Mismatched field paths, error codes, and messages
  • Only the first 20 issues to limit log size

The log gets its method and path from the Axios response instead of a name repeated at the call site. Changing an endpoint does not leave an obsolete warning label behind.

A mismatch repeated in every list item could flood the issue list, so the same request and field are reported once per session. The resulting count approximates sessions encountering it rather than the total number of mismatches.

This detects invalid responses without blocking them, and the return type still trusts the Axios generic. I kept it as a step for finding contract differences while preserving existing behavior. Confirmed responses can move to parsed results; new APIs, authentication, and streams use them from the start where failure can stay within a request or event.

Use validation logs to correct schemas

When SCHEMA_MISMATCH appeared, I used the request path and app to find the schema, then compared OpenAPI with the actual response. I first needed to distinguish a server contract violation from an incorrect frontend schema to know which side to change.

text
SCHEMA_MISMATCH 로그
  → 요청 경로와 앱으로 호출부·스키마 특정
  → OpenAPI 계약과 실제 응답 대조
  → 프론트 스키마 오류 / 서버 스펙 위반 / 판단 불가로 분류
  → 프론트 오류만 스키마와 파생 타입 수정
  → 계약 테스트와 뮤테이션 체크
  → 리뷰·검증 후 PR 반영

The goal is not merely to remove a warning. If the actual response violates OpenAPI, I do not silently add .nullish() to the frontend schema. I classify a server-contract violation and stop the frontend fix. If an anyOf response union was modeled with only one member, I model both and add contract tests for both.

This exposed unpaginated lists and bodyless success responses being forced into the old envelope schema. Another API returned two successful shapes while the frontend modeled only one. Some warnings reflected the frontend’s misunderstanding rather than a server error.

Recover only invalid URL fields

People can edit a URL or arrive through an old link. An unknown status should not also erase their search term and the detail they were viewing.

The first version parsed the whole object and used default conditions on failure. An invalid status reset the valid keyword, page, and detail-drawer ID too. Independent conditions needed independent recovery.

Schemas whose fields can recover independently now have per-field fallbacks.

typescript
const searchParamsSchema = z.object({
  pageNumber: z.coerce.number().int().positive().catch(1),
  status: z.enum(STATUSES).optional().catch(undefined),
  keyword: z.string().optional().catch(undefined),
  id: z.coerce.number().optional().catch(undefined),
});
 
const params = useTypedQueryParams({ schema: searchParamsSchema });

An unknown status is dropped while the keyword and detail screen remain. Whole-object fallback stays as the final safeguard against an unexpected structure. Screens rebuilding URLs for sharing or tabs explicitly use .passthrough() so they do not silently remove parameters their schema does not own.

A field recovered by .catch makes the object parse succeed, so it leaves no mismatch log. Old links stop generating repeated warnings, but the frequency and source of invalid values disappear too. Parameters that need tracking must fail at the object boundary instead. I also avoid silent recovery for values such as payments and permissions.

Show invalid fields and block form submission

In a form, the person entering the value can correct it. I show the reason on the field and stop submission rather than passing the invalid value through or silently replacing it.

User-facing forms connect React Hook Form and zodResolver, using schemas as the source of input types and submission conditions.

tsx
function useZodForm<TSchema extends z.ZodType>({ schema, ...options }: Options<TSchema>) {
  return useForm<z.infer<TSchema>>({
    ...options,
    resolver: zodResolver(schema),
  });
}

Reservation and review step forms have per-step schemas so fields in a future step do not block the current one.

The Ant Design search forms in admin work differently. createSearchFilter(schema) narrows field names through z.infer and uses the schema when reading URLs. It does not replace all Ant Design submission validation. Field names are checked by the compiler, and external URL values are handled at runtime.

Derive narrowing from validation results

A custom guard like this was one of the most dangerous assertions.

typescript
function isCompletedPayment(value: unknown): value is CompletedPayment {
  return value != null;
}

This function checks only that the value is not null. Yet returning true tells the compiler to accept CompletedPayment. Adding fields to that type does not change this check, so it promises far more than it verifies.

When I only need to classify a completed payment, I return schema success as a boolean. It does not claim to narrow the entire value to a type it has not fully checked.

typescript
const completedPaymentSchema = z.object({
  method: z.literal('NORMAL'),
  status: z.literal('COMPLETED'),
});
 
function isCompletedPayment(value: unknown): boolean {
  return completedPaymentSchema.safeParse(value).success;
}

When validated data is needed, I use safeParse's data instead of hand-declaring another value is T predicate.

typescript
const result = completedPaymentSchema.safeParse(value);
if (!result.success) return;
 
useCompletedPayment(result.data);

Unknown tab or sort keys returned as strings by UI libraries can be ignored with z.enum(...).optional().catch(undefined) where that behavior is appropriate. Already narrowed row data is not revalidated. I first distinguish runtime evidence from established type flow.

Failure policy depends on the boundary's consumer

The roles differ even with the same tool.

BoundaryResult usedFailure handlingReason
Existing API responsesOriginal valuesLog and pass throughAvoid breaking working screens during introduction
New APIs, auth, streamsParsed valuesFail the request or eventFailure scope can be isolated
URL conditionsParsed valuesField fallback or object fallback plus loggingPreserve valid conditions from old links
User formsParsed valuesBlock submission and explain the fieldUsers can correct input
External-library valuesDepends on contextIgnore, fall back, or blockMatch the value's impact

A schema mismatch and a decision to stop the screen are separate questions. Sharing a validation tool does not require one failure behavior.

Test what happens when validation fails

Tests include values after a mismatch, rather than only valid responses. Existing APIs must return the original object with undeclared fields intact. A new enum must record the actual request method, path, and mismatched field. Bodyless successes and unpaginated lists must remain valid contracts.

For URLs, I check that one invalid enum does not remove the keyword, page, or detail ID. Forms must block the invalid value and show an error someone can correct. The cases also cover reporting an identical mismatch only once per session.

After correcting schemas, I temporarily restored old definitions and checked that new tests failed. Fixtures changed incorrectly alongside schemas can otherwise keep tests green. Where possible, response mocks use satisfies and contract tests. Contracts available on a real dev server are not considered verified by mocks alone.

Some custom guards remain where validation costs more or where they supplement an external library’s types. Their tests state which fields they check. OpenAPI and frontend schemas can still disagree, so I continue to compare them through real-server contracts and mismatch logs.