# 02 — zod Types & Plugins

Config fields, params, and result shapes are declared with zod. The **UI widget is derived from the zod
type** — there is no `.uiComponent()`. Plugins (chained methods) attach labels, descriptions, defaults,
and behaviors.

| Type | Widget | | Plugin call | Applies to | Effect |
|---|---|---|---|---|---|
| `z.string()` | single-line | | `.label(text)` | any field | display label (native `meta.title`) |
| `z.text()` | multi-line | | `.describe(text)` | any field | description / config hint |
| `z.json()` | multi-line — arbitrary JSON, **parsed for you** | | | | |
| `z.number()` | numeric stepper | | `.example(v)` | field / result | sample value (native `meta.examples`) |
| `z.boolean()` | toggle | | `.optional()` | any field | not required (required is default) |
| `z.enum([...])` | dropdown | | `.default(v)` | any field | default value |
| `z.date()` | date picker | | `.shared()` | config field | `shared:true` (OAuth client id/secret) |
| `z.object({...})` | nested form | | `.dictionary(ref)` | param field | dynamic options + auto `dependsOn` (see [05-dictionaries.md](05-dictionaries.md)) |
| `z.array(inner)` | list | | `.labels({ value: 'Label' })` | `z.enum` | UI shows the label; the flow stores and `execute` receives the VALUE |
| `z.union([...])` | the **first** branch's widget — see below | | | | |
| `z.filesUploadOptions([...])`, `z.filesListOptions([...])`, `z.filesGetOptions([...])`, `z.filesDeleteOptions([...])` | nested form — the platform's own file-storage options, one group per Files call (see [03-parameters.md](03-parameters.md)) | | | | |
| | | | `.secret()` | `z.string()` config field | Masks the input, with a reveal toggle. **Display only** — see below |

Notes:

- **The Widget column is the PARAM rendering.** A `configItems` field publishes a smaller, closed set of
  types — notably `z.number()` lands on `string` and gets a plain text box, not a stepper. See
  [Config fields publish a smaller set of types](#config-fields-publish-a-smaller-set-of-types).
- **Required is the default** — mark optional fields with `.optional()`.
- `.shared()` marks a config field as a **shared** (global) config value rather than per-app — used for
  OAuth client id/secret and other secrets. It is a **UI directive**: it decides who fills the field in and
  in which dialog, not how your handler reads it. `config.clientId` is the same either way, and a service
  installed as a *custom* extension has no shared tier at all — the customer fills in every item in one
  dialog. See `configItems` in [01-service-structure.md](01-service-structure.md) and the output mapping in
  [11-output-model.md](11-output-model.md).
- `.secret()` marks a **`z.string()` config field** as sensitive: the console renders it masked, with a
  toggle to reveal it. It is **display only** — the value is stored, sent and validated exactly as it would
  be without it, so it hides a credential from someone looking over a shoulder rather than protecting it.
  It has no effect anywhere else: not on `z.text()`, which has no password mode, and not on an action or
  trigger param, whose value lives in the flow definition and is readable through the API — masking that
  input would suggest a protection that is not there. Put credentials in a `z.string()` config item.
- `.dictionary(ref)` binds a param to a dictionary for dynamic options and auto-derives its `dependsOn`.
- `.labels({ value: 'Label' })` gives each declared VALUE a display name. The dropdown shows the label, the
  flow element stores the value, and your handler receives the value — so **renaming a label never breaks a
  saved flow**. `.default(v)` takes a value, not a label.
  (There is no `.map({ Label: value })` — it took its argument the other way round, treating labels as the
  accepted values. It throws when the service loads, naming `.labels()`.)

## `z.json()` — a JSON value typed into a textarea

Use it for a parameter whose shape you do not want to declare: a request body to pass through, a bag of
options an API takes verbatim.

```js
params: z.object({
  payload: z.json().label('Payload').describe('Sent as the request body.'),
}),
execute: async ({ params }) => {
  // `params.payload` is already a JSON VALUE — an object, an array, a number. Not text.
  return apiRequest({ url: '/items', method: 'POST', body: params.payload })
}
```

The field renders as a textarea and the caller supplies **text**, so the parameter publishes as a string —
but the runtime parses it before your handler runs, and `execute` receives the parsed value. **Never call
`JSON.parse` on it yourself.**

- **Text that will not parse is rejected** the way any wrong-typed value is: the user sees
  `«Payload» must be valid JSON` against the field, before your handler is reached.
- **A value that is already a structure passes through untouched** — a flow can bind the parameter to an
  expression that evaluates to an object rather than to text, and both work.
- **Every JSON value is accepted**, not just objects and arrays: `42`, `"text"`, `true` and `null` all parse.
  Bare words do not — JSON wants a string quoted, so `hello` is refused and `"hello"` is accepted.
- **Required by default**, like every other field; add `.optional()` if it is not.
- Its inferred TypeScript type is `unknown`, because that is honestly what arbitrary JSON is. Narrow it in
  your handler before reading properties off it.

`z.json()` declares nothing about the value's SHAPE. When you do know the shape, declare it — a
`z.object({ … })` gives the user a real form and gives you a checked value, where this gives a textarea and
`unknown`.

## The type list is CLOSED

The table above is what a service declares day to day. A few more types are accepted and rarely reached for:

| Type | Widget | Notes |
|---|---|---|
| `z.bigint()` | numeric stepper | your handler receives a `BigInt`. A fractional value is refused rather than rounded, since the conversion would lose information |
| `z.literal(v)` | the widget of `v`'s own type — a toggle for `z.literal(true)`, a stepper for `z.literal(7)` | a literal of several values of *different* types has no such type and publishes as text |
| `z.record(k, v)` | nested form with no declared fields, because the keys are open | the values are converted against `v` like any other declaration |
| `z.any()` / `z.unknown()` | single-line | declares nothing: the value reaches your handler exactly as it arrived, unconverted |

**Those two tables together are the whole surface.** A declaration outside them — `z.tuple()`,
`z.intersection()`, a modifier a future zod release adds — **fails when the extension loads**, naming the
parameter and what it declared. It is not published as a best guess. A `z.union([...])` is supported, but it
is not a way round this: **every branch is held to the same surface**, so a union with a `z.tuple()` in it is
refused exactly as a bare one is.

That is deliberate, and it is worth knowing why the runtime is strict here rather than permissive. A type it
cannot classify used to fall through to `STRING`, which broke two things at once and neither of them loudly:
the parameter published as text (a list stopped being a list, a toggle became a text box) **and** the field
dropped out of type coercion, so the string values a flow supplies were never converted. A `z.boolean()`
under an unrecognised modifier therefore handed `execute` `false` for an explicit `'true'`. A deploy that
stops is better than a parameter that looks fine and is wrong.

These modifiers are looked THROUGH — the parameter publishes and coerces as the type underneath:

| Modifier | Published as |
|---|---|
| `.optional()` / `.nullable()` / `.nullish()` | the inner type, not required |
| `.default(v)` / `.prefault(v)` | the inner type, not required, with `defaultValue` |
| `.readonly()` | the inner type |
| `.nonoptional()` | the inner type, required — it overrides an `.optional()` inside it |
| `.pipe(…)` / `.transform(fn)` | the **input** side. `bodyParams` describes what a caller supplies, which is what the console renders a form for; what your handler receives afterwards is the pipe's business |
| `z.lazy(() => …)` | the type the thunk returns |

### `.catch(v)` is refused — use `.default(v)`

`.catch(v)` looks like the natural way to say "if this isn't a valid list, use an empty one", so it is what
a migrator reaches for. A parameter may not use it, and the extension will not load if one does.

The reason is not the published type: `.catch()` **replaces an invalid value with the fallback instead of
reporting it**. A user who typed something wrong gets no error and their input is silently discarded.

```js
tags: z.array(z.string()).catch([])    // ✗ refused — a bad value becomes [] with no error
tags: z.array(z.string()).default([])  // ✓ same fallback when ABSENT; a bad value still fails
```

## `z.union([...])` — branch order is the contract

A union is how a parameter accepts more than one shape — an id or a list of ids, a URL or a blank. Two rules
govern it, and **both read the branches left to right, in the order you declared them.**

### The first branch decides what is published

The console draws one widget, so it draws the first branch's.

```js
recordIds: z.union([z.array(z.string()), z.string()])   // a LIST — accepts a list AND a single string
recordIds: z.union([z.string(), z.array(z.string())])   // a text box — accepts exactly the same two
```

Both declarations validate identically. They **publish differently**, and nothing in the declaration says so
— so reordering branches to read more nicely silently changes what the flow author is shown. Put the shape
you want on the form first, and treat the order as part of the contract.

### Coercion tries the branches in that same order, and the first that fits wins

A flow supplies every value as text, so a union has to convert before it can tell which branch applies.

```js
flag: z.union([z.boolean(), z.string()])   // 'true' → your handler receives the boolean  true
flag: z.union([z.string(), z.boolean()])   // 'true' → your handler receives the string  'true'
```

**A branch that accepts anything hides every branch after it.** `z.string()` takes `'true'` as-is, so the
boolean branch is never reached. If one branch is a fallback, declare it last.

That is worth spelling out for booleans, because the failure is silent. In `z.union([z.string(),
z.boolean()])` the field draws as the **first** branch's widget — a text box, not a toggle — the boolean
vocabulary below is never applied, and `execute` receives the string `'false'`, which is truthy: `if
(params.flag)` runs the *on* path for an explicit *off*. Put the boolean branch first.

### A blank means "not set"

`''` is a real value on a plain `z.string()`, which is why a cleared text box fails an optional formatted
field. On a union it is absence, and that is the spelling to reach for:

```js
webhookUrl: z.url().optional()                            // blank → «Webhook URL» is invalid — expects a URL
webhookUrl: z.union([z.url(), z.literal('')]).optional()  // blank → not provided
```

See [16-gotchas.md](16-gotchas.md) for the rest of that trap.

## Config fields publish a smaller set of types

A `configItems` field is drawn from its declaration much as a param is — `z.boolean()` is a toggle,
`z.enum([...])` a dropdown, `z.text()` a textarea — but a config item is published through a **smaller,
closed vocabulary** than a param, and anything outside it lands on `string`:

| Declared in `configItems` | Published `type` | Control |
|---|---|---|
| `z.string()` | `string` | single-line text |
| `z.text()` | `text` | textarea |
| `z.boolean()` | `bool` | checkbox |
| `z.enum([...])` | `choice` | dropdown |
| `z.date()` | `date` | date picker |
| **`z.number()`** | **`string`** | **single-line text — there is no numeric config type, so no stepper** |

`z.number()` is the one that surprises: as a *param* it publishes `NUMBER` and renders a numeric stepper, in
`configItems` it publishes `type: "string"` and renders a plain text box. `getServiceConfigItems()` reports
`"string"` for it, and a test asserting the published config items must expect that. **Your handler still
receives a number** — the declared type is what the value is converted to before validation (see below), so
the widget is the only thing that differs. Declare `z.number()` anyway when the value is a number: it is
what gives you the conversion and the validation.

The two other differences are structural:

| Declared in `configItems` | What happens |
|---|---|
| `z.object({...})`, `z.array(inner)` | **no config control** — a config value is one field, not a form. Declare structured input as a param |
| `.dictionary(ref)` | **ignored** — a config value is resolved before any method runs, so there is no invocation to fetch options for. Use `z.enum([...])` for a fixed set of choices |

`z.date()` in a config field is a date, with no time of day. Declare a string if you need one.

**Every value is converted to the declared type before validation, and you never declare that
conversion.** `z.coerce.*` does not add to it, so `z.coerce.number()` and `z.number()` behave identically,
as do the boolean and string forms. The one exception is `z.date()` — declare **`z.coerce.date()`** when your handler wants a real `Date`
instance rather than the string or epoch-millis the caller sent.

### What a boolean accepts

A `z.boolean()` is rarely filled in by hand — its value usually arrives from a spreadsheet column, a CSV
export or an upstream vendor's payload, and each of those spells *yes* its own way. So the conversion
recognises all of these, after trimming surrounding whitespace and lower-casing, along with the real
booleans `true`/`false` and the numbers `1`/`0`:

| | Accepted spellings |
|---|---|
| **true** | `true` `1` `yes` `y` `t` `on` `checked` `enabled` `enable` `active` |
| **false** | `false` `0` `no` `n` `f` `off` `unchecked` `disabled` `disable` `inactive` |

So `'YES'`, `' Off '` and `'Enabled'` all work.

**Anything else is a validation error, not a guess.** `'maybe'`, `'2'`, `'tr'`, a list or an object each
fail with `«Field» is invalid — expects true or false`. That matters more than the width of the list: a
conversion that quietly turned an unrecognised value into `false` would read an explicit *on* as *off* and
never say so.

A blank is absence rather than `false` — see [16-gotchas.md](16-gotchas.md).

If you need a spelling that is not listed, the test for adding one is that **it has an unambiguous opposite
and the pair is added together.** A word with no opposite is a status, not a boolean, and belongs in a
`z.enum([...])`.

## Your constraints are enforced, in two places

Everything you declare on a field — `.min()`, `.max()`, `.int()`, `.regex()`, `z.email()`, enum membership,
your own message in `.min(3, 'Too short')` — is checked before your handler runs, and the failure names the
field by its `.label()`:

```
«Retries» must be at least 1
«Site» is invalid — expects a URL, e.g. https://example.com
«Region» must be one of: us, eu
```

The console checks the *same* schema while the form is being filled in, so a config dialog reports the same
problem in the same words rather than letting a bad value through to a failed run. Two consequences worth
knowing:

- **A function-backed rule cannot be checked in the form.** `.refine()`, `.superRefine()`, `.transform()` and
  `.trim()` do not survive the trip to the browser, so the console cannot apply them. They still run at
  execution time. The form is never *stricter* than your schema — only ever more permissive — so a value the
  form accepts may still be rejected by a run.
- **Absence follows your modifiers, not the transport.** Whether an empty field arrives as `undefined`,
  `null`, or the declared `.default(v)` is decided by what your schema accepts, so you write `.optional()` or
  `.nullable()` — whichever reads naturally — instead of guarding for all three. One exception: an empty
  string on a `z.string()` field is a *value*, not absence, so it does not fail a required field. See
  [16-gotchas.md](16-gotchas.md).
