# Validation and serialization

> Choose how values such as dates and URLs travel between your client and server.

JSON has no `Date` type. If a handler returns a date, the client normally receives a string. A codec lets you choose to receive a `Date` instead.

Start with ordinary JSON, then add validation or conversion when the values in your application need it. The examples below make that change one step at a time.

## Start with native JSON

Suppose an endpoint accepts a date and returns the following day. The smallest contract uses ordinary TypeScript types:

```ts filename="src/api/contract.ts"
const tomorrow = route.post('/tomorrow', {
  body: request.json<{ at: string }>(),
  responses: {
    200: response.json<{ at: string }>(),
  },
})
```

This is **type-only mode**. The client sends a string, the handler receives a string, and the response contains a string.

Use type-only declarations for already-trusted values or when another layer validates them.

> **Warning: Types do not check incoming data**
>
> A generic type disappears at runtime. Pass a schema when a request can contain untrusted input.

## Add runtime validation

When the value comes from an untrusted request, replace the type argument with a Standard Schema:

```diff filename="src/api/contract.ts"
+import { z } from 'zod'

 const tomorrow = route.post('/tomorrow', {
-  body: request.json<{ at: string }>(),
+  body: request.json(z.object({ at: z.iso.datetime() })),
   responses: {
-    200: response.json<{ at: string }>(),
+    200: response.json(z.object({ at: z.iso.datetime() })),
   },
 })
```

The application types have not changed—`at` is still a string on both sides. What changed is the boundary:

- malformed dates are rejected before the handler runs;
- an invalid response is caught before it leaves the server;
- TypeScript now infers the types from the schemas.

Zod is used here because it is familiar, not because `@hulla/api` requires it. Any [Standard Schema](https://standardschema.dev/) compatible library works.

## Convert values inside the server

Validation can also transform a wire value for the handler. Change the request schema from `string → string` to `string → Date`, and reverse that direction for the response:

```diff filename="src/api/contract.ts"
 const tomorrow = route.post('/tomorrow', {
-  body: request.json(z.object({ at: z.iso.datetime() })),
+  body: request.json(
+    z.object({
+      at: z.iso.datetime().transform((value) => new Date(value)),
+    }),
+  ),
   responses: {
-    200: response.json(z.object({ at: z.iso.datetime() })),
+    200: response.json(
+      z.object({
+        at: z.date().transform((value) => value.toISOString()),
+      }),
+    ),
   },
 })
```

Now the handler works with `Date` without doing manual parsing:

```ts filename="src/api/server.ts"
tomorrow: ({ body, response }) => {
  const at = new Date(body.at.getTime())
  at.setUTCDate(at.getUTCDate() + 1)

  return response(200, { at })
}
```

The HTTP client still sends and receives ISO strings. A one-way transform changes the value for the side that runs that schema; it does not define a reversible client representation.

Use this when rich values belong inside the server but the client should stay close to HTTP.

## Share rich values with a codec

If both the client and server should use `Date`, describe both directions with a codec:

```ts filename="src/api/date-codec.ts"
import { codec } from '@hulla/api'
import { z } from 'zod'

export const dateCodec = codec(
  z.object({ at: z.iso.datetime() }),
  z.object({ at: z.date() }),
  {
    decode: ({ at }) => ({ at: new Date(at) }),
    encode: ({ at }) => ({ at: at.toISOString() }),
  }
)
```

Then replace the directional transforms with the shared codec:

```diff filename="src/api/contract.ts"
+import { dateCodec } from './date-codec'

 const tomorrow = route.post('/tomorrow', {
-  body: request.json(
-    z.object({
-      at: z.iso.datetime().transform((value) => new Date(value)),
-    }),
-  ),
+  body: request.json(dateCodec),
   responses: {
-    200: response.json(
-      z.object({
-        at: z.date().transform((value) => value.toISOString()),
-      }),
-    ),
+    200: response.json(dateCodec),
   },
 })
```

The wire format remains JSON with an ISO string. The client and handler now both use `Date`:

```ts filename="src/app.ts"
const result = await api.tomorrow({
  body: { at: new Date('2030-01-01T12:00:00.000Z') },
})

result.body.at // Date
```

A codec is the right choice when the conversion belongs to the contract itself. It keeps callers from repeating `new Date(...)` and `toISOString()` around every request.

## Choose the smallest useful boundary

| Choice                | Client value | Handler value | Runtime check |
| --------------------- | ------------ | ------------- | ------------- |
| Type argument         | `string`     | `string`      | No            |
| Schema                | `string`     | `string`      | Yes           |
| Directional transform | `string`     | `Date`        | Yes           |
| Codec                 | `Date`       | `Date`        | Yes           |

A practical default is:

1. Use a schema for untrusted input.
2. Add a transform when only the server needs a richer value.
3. Reach for a codec when both sides should share that richer value.

Next, see where these declarations run in [Request and response data](https://hulla.dev/docs/api/core/request-transport.md), or continue to [Error handling](https://hulla.dev/docs/api/core/errors.md).
