Skip to content

chat(): InferSchemaType<z.ZodType<T>> resolves to unknown — forces .parse() round trip or cast #562

@tombeckenham

Description

@tombeckenham

Summary

chat({ outputSchema }) returns Promise<unknown> (or events typed T = unknown) when the schema is a Zod schema, even though Zod 4.2+ "natively supports StandardJSONSchemaV1" per the inline comment in types.ts. Callers are forced to either as T cast or call schema.parse(result) a second time purely to recover the type binding — even though the orchestrator already validated upstream.

Repro

import { chat } from '@tanstack/ai';
import { z } from 'zod';

const schema = z.object({ greeting: z.string() });

const result = await chat({
  adapter,
  systemPrompts: [],
  messages: [{ role: 'user', content: 'hi' }],
  stream: false,
  outputSchema: schema,
});

// Expected: result is typed `{ greeting: string }`
// Actual:   result is typed `unknown`

Same problem in the streaming branch: StructuredOutputCompleteEvent<T> resolves with T = unknown, so event.value.object is unknown.

Root cause

// @tanstack/ai/dist/esm/types.d.ts
export type SchemaInput = StandardJSONSchemaV1<any, any> | JSONSchema;
export type InferSchemaType<T> =
  T extends StandardJSONSchemaV1<infer TInput, unknown> ? TInput : unknown;

StandardJSONSchemaV1.Props extends StandardTypedV1.Props with a required jsonSchema: Converter field. Zod 4.4.3 declares $ZodType's ~standard as StandardSchemaV1.Props only — no jsonSchema converter on the type. So z.ZodType<T> does not structurally satisfy StandardJSONSchemaV1<T, unknown>, and InferSchemaType falls through to unknown.

(Interestingly, Zod ships a StandardSchemaWithJSONProps extends StandardSchemaV1.Props, StandardJSONSchemaV1.Props {} interface that would fix this, but $ZodType doesn't use it. Looks like an in-progress upstream change.)

Proposed fixes (in preference order)

1. Broaden InferSchemaType to also extract from StandardSchemaV1

Minimal type-level change, library-agnostic across Zod / ArkType / Valibot (all implement StandardSchemaV1 structurally):

import type { StandardSchemaV1 } from '@standard-schema/spec';

export type SchemaInput =
  | StandardJSONSchemaV1<any, any>
  | StandardSchemaV1<any, any>      // ← add
  | JSONSchema;

export type InferSchemaType<T> =
  T extends StandardJSONSchemaV1<infer I, unknown> ? I
  : T extends StandardSchemaV1<infer I, unknown> ? I   // ← add
  : unknown;

StandardJSONSchemaV1<I, O> is structurally narrower than StandardSchemaV1<I, O>, so the existing branch still wins when the schema carries a JSON-Schema converter.

The runtime would then need to handle StandardSchemaV1 inputs without a jsonSchema converter — either by trusting Zod v4.2+ to attach it at runtime, or by accepting an optional JSON-Schema-converter argument when none is on the schema.

2. Structural duck-type on output

Library-agnostic without widening SchemaInput:

export type InferSchemaType<T> =
  T extends StandardJSONSchemaV1<infer I, unknown> ? I
  : T extends { _output: infer O } ? O           // Zod
  : T extends StandardSchemaV1<infer I, unknown> ? I
  : unknown;

More fragile (relies on internal field names) but works today without coordinating with Zod.

3. Explicit wrapper helper

import { fromZod } from '@tanstack/ai/zod';
const result = await chat({ outputSchema: fromZod(schema), ... });

Worst ergonomics, but no upstream coordination needed.

Why this matters

It's currently impossible to call chat({ outputSchema: zodSchema }) and get back a typed result without one of:

  • result as T — bypasses the type system
  • schema.parse(result) — redundant validation (the orchestrator already validated)
  • Defining the schema as a StandardJSONSchemaV1 instance manually — defeats the point of using Zod

The fix is a ~4-line type change. Happy to send a PR if option 1 sounds reasonable.

Versions

  • @tanstack/ai: 0.17.0
  • zod: 4.4.3
  • TypeScript: tsgo (latest)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions