Skip to content

feat(api): server-side Zod transforms for full e2e type safety with Treaty client #2175

@andrew-bierman

Description

@andrew-bierman

Follow-up to #2083. Landed in that PR: 100% removal of axios, full Treaty migration, and a single source-of-truth server type graph. What remains is a class of 28 `as unknown as LocalType` casts at the hook boundary in `apps/expo`, all caused by one root issue.

Root cause

Treaty infers Elysia's response types from the Zod schemas. Where schemas reflect Drizzle column types directly (e.g. `createdAt: z.date()`, `price: z.number().nullable()`), the inferred wire type is `Date` / `number | null`. But the JSON actually sent over the wire serializes Dates to ISO strings. The client consumers (UI components, date formatters, comparisons) are written against the wire reality — `string` / `string | null`.

The mismatch: Treaty types the response as `{ createdAt: Date }`, the actual payload is `{ createdAt: "2026-04-14T..." }`, consumers expect `string`. The 28 casts paper over this at the hook boundary.

Scope

Full fix: wrap every datetime / server-specific column in its response schema with `.transform()` so the wire type matches what consumers already consume.

Affected server schemas — every Zod schema used as a route `response` that mirrors a Drizzle row with Date columns:

  • `packages/api/src/schemas/packs.ts`
  • `packages/api/src/schemas/packTemplates.ts`
  • `packages/api/src/schemas/catalog.ts`
  • `packages/api/src/schemas/feed.ts`
  • `packages/api/src/schemas/trips.ts` (if extracted)
  • `packages/api/src/schemas/guides.ts`
  • `packages/api/src/schemas/users.ts`
  • `packages/api/src/schemas/weather.ts`
  • `packages/api/src/schemas/auth.ts`

Typical transform pattern:
```ts
createdAt: z.date().transform((d) => d.toISOString()),
updatedAt: z.date().transform((d) => d.toISOString()),
expiresAt: z.date().nullable().transform((d) => d?.toISOString() ?? null),
```

Affected client files — 24 hook/store files with `as unknown as` casts that should be removed once the server types align:

```
apps/expo/features/ai-packs/hooks/useGeneratedPacks.ts
apps/expo/features/ai/hooks/useReportContent.ts
apps/expo/features/ai/hooks/useReportedContent.ts
apps/expo/features/ai/hooks/useUpdateReportStatus.ts
apps/expo/features/catalog/hooks/useCatalogItems.ts
apps/expo/features/catalog/hooks/useCatalogItemsCategories.ts
apps/expo/features/catalog/hooks/useSimilarItems.ts
apps/expo/features/pack-templates/hooks/useGenerateTemplateFromOnlineContent.ts
apps/expo/features/pack-templates/store/packTemplateItems.ts
apps/expo/features/packs/hooks/useAllPacks.ts
apps/expo/features/packs/hooks/useDuplicatePack.ts
apps/expo/features/packs/hooks/useImageDetection.ts
apps/expo/features/packs/hooks/usePackDetailsFromApi.ts
apps/expo/features/packs/hooks/usePackGapAnalysis.ts
apps/expo/features/packs/hooks/usePackItemDetailsFromApi.ts
apps/expo/features/packs/hooks/useSeasonSuggestions.ts
apps/expo/features/packs/store/packItems.ts
apps/expo/features/packs/utils/uploadImage.ts
apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts
apps/expo/features/trips/hooks/useAllTrips.ts
apps/expo/features/weather/hooks/useWeatherAlert.ts
apps/expo/features/weather/lib/weatherService.ts
apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts
```

Approach

  1. For each response schema, identify every Date field and wrap with `.transform(d => d.toISOString())`.
  2. For nullable Dates, wrap with `.nullable().transform(d => d?.toISOString() ?? null)`.
  3. Replace each local type (`Pack`, `PackItem`, `CatalogItem`, `Trip`, etc.) with the Treaty-inferred equivalent derived from `apiClient`:
    ```ts
    type PackListResponse = NonNullable<Awaited<ReturnType>['data']>;
    export type Pack = PackListResponse[number];
    ```
  4. Remove each `as unknown as X` cast. Fix any consumer errors that surface.
  5. Verify:
    • Root `tsc --noEmit` clean
    • No `as unknown as` in `apps/expo/features`
    • No new `biome-ignore` or `@ts-expect-error`

Done when

  • All response schemas transform dates to ISO strings at the wire boundary
  • Local domain types (`Pack`, `PackItem`, `CatalogItem`, `Trip`, `Post`, `Comment`, `PackTemplate`, `PackTemplateItem`, `TrailConditionReport`) are derived from Treaty
  • Zero `as unknown as` casts in `apps/expo/features` and `apps/expo/lib/api`
  • `bun run check-types` and `bun run lint` both green
  • No new escape hatches (`any`, `@ts-expect-error`, biome-ignore)

Why separate from #2083

#2083 is at ~150 files touched and merges cleanly today. This cleanup touches a different dimension (server schemas + client type consumers, ~60 files) and benefits from isolated review. Should base on #2083's branch so it merges after.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions