/ utils / semanticNumber.ts
semanticNumber.ts
 1  import { z } from 'zod/v4'
 2  
 3  /**
 4   * Number that also accepts numeric string literals like "30", "-5", "3.14".
 5   *
 6   * Tool inputs arrive as model-generated JSON. The model occasionally quotes
 7   * numbers — `"head_limit":"30"` instead of `"head_limit":30` — and z.number()
 8   * rejects that with a type error. z.coerce.number() is the wrong fix: it
 9   * accepts values like "" or null by converting them via JS Number(), masking
10   * bugs rather than surfacing them.
11   *
12   * Only strings that are valid decimal number literals (matching /^-?\d+(\.\d+)?$/)
13   * are coerced. Anything else passes through and is rejected by the inner schema.
14   *
15   * z.preprocess emits {"type":"number"} to the API schema, so the model is
16   * still told this is a number — the string tolerance is invisible client-side
17   * coercion, not an advertised input shape.
18   *
19   * .optional()/.default() go INSIDE (on the inner schema), not chained after:
20   * chaining them onto ZodPipe widens z.output<> to unknown in Zod v4.
21   *
22   *   semanticNumber()                              → number
23   *   semanticNumber(z.number().optional())         → number | undefined
24   *   semanticNumber(z.number().default(0))         → number
25   */
26  export function semanticNumber<T extends z.ZodType>(
27    inner: T = z.number() as unknown as T,
28  ) {
29    return z.preprocess((v: unknown) => {
30      if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) {
31        const n = Number(v)
32        if (Number.isFinite(n)) return n
33      }
34      return v
35    }, inner)
36  }