Skip to content

Conversation

@ksinghal
Copy link
Contributor

@ksinghal ksinghal commented Dec 23, 2025

convex-helpers Zod4 Optional Codec Fix

Problem

When using zodOutputToConvex() with an optional codec (e.g., dateToMsCodec.optional()), TypeScript incorrectly infers the type as never instead of VOptional<VFloat64>.

This happens because ConvexValidatorFromZodOutput uses ConvexValidatorFromZod (which looks at input types) for nested types like $ZodOptional, instead of using itself recursively (which looks at output types).

For codecs, the input type is something like z.date() which is not a valid Convex type, causing the type inference to fail.

Files to Patch

  • node_modules/convex-helpers/server/zod4.ts
  • node_modules/convex-helpers/server/zod4.d.ts

Patch for zod4.ts

Location

Around line 1028-1048 (the ConvexValidatorFromZodOutput type definition)

BEFORE (Original)

/**
 * Return type of {@link zodOutputToConvex}.
 */
export type ConvexValidatorFromZodOutput<
  Z extends zCore.$ZodType,
  IsOptional extends "required" | "optional",
> =
  // `unknown` / `any`: we can't infer a precise return type at compile time
  IsUnknownOrAny<Z> extends true
    ? GenericValidator
    : // z.default()
      Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> // output: always there
      ? VRequired<ConvexValidatorFromZod<Inner, "required">>
      : // z.pipe()
        Z extends zCore.$ZodPipe<
            infer _Input extends zCore.$ZodType,
            infer Output extends zCore.$ZodType
          >
        ? ConvexValidatorFromZod<Output, IsOptional>
        : // All other schemas have the same input/output types
          ConvexValidatorFromZodCommon<Z, IsOptional>;

AFTER (Patched)

/**
 * Return type of {@link zodOutputToConvex}.
 */
export type ConvexValidatorFromZodOutput<
  Z extends zCore.$ZodType,
  IsOptional extends "required" | "optional",
> =
  // `unknown` / `any`: we can't infer a precise return type at compile time
  IsUnknownOrAny<Z> extends true
    ? GenericValidator
    : // z.default()
      Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> // output: always there
      ? VRequired<ConvexValidatorFromZodOutput<Inner, "required">>
      : // z.pipe() - use output schema for zodOutputToConvex
        Z extends zCore.$ZodPipe<
            infer _Input extends zCore.$ZodType,
            infer Output extends zCore.$ZodType
          >
        ? ConvexValidatorFromZodOutput<Output, IsOptional>
        : // z.optional() - handle here to use output types consistently
          Z extends zCore.$ZodOptional<infer Inner extends zCore.$ZodType>
          ? VOptional<ConvexValidatorFromZodOutput<Inner, "optional">>
          : // z.nullable() - handle here to use output types consistently
            Z extends zCore.$ZodNullable<infer Inner extends zCore.$ZodType>
            ? ConvexValidatorFromZodOutput<Inner, IsOptional> extends Validator<
                any,
                "optional",
                any
              >
              ? VUnion<
                  | ConvexValidatorFromZodOutput<Inner, IsOptional>["type"]
                  | null
                  | undefined,
                  [
                    VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>,
                    VNull,
                  ],
                  "optional",
                  ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]
                >
              : VUnion<
                  | ConvexValidatorFromZodOutput<Inner, IsOptional>["type"]
                  | null,
                  [
                    VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>,
                    VNull,
                  ],
                  IsOptional,
                  ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]
                >
            : // All other schemas have the same input/output types
              ConvexValidatorFromZodCommon<Z, IsOptional>;

Patch for zod4.d.ts

Location

Line 464 (the ConvexValidatorFromZodOutput type declaration - single long line)

BEFORE (Original)

export type ConvexValidatorFromZodOutput<Z extends zCore.$ZodType, IsOptional extends "required" | "optional"> = IsUnknownOrAny<Z> extends true ? GenericValidator : Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> ? VRequired<ConvexValidatorFromZod<Inner, "required">> : Z extends zCore.$ZodPipe<infer _Input extends zCore.$ZodType, infer Output extends zCore.$ZodType> ? ConvexValidatorFromZod<Output, IsOptional> : ConvexValidatorFromZodCommon<Z, IsOptional>;

AFTER (Patched)

export type ConvexValidatorFromZodOutput<Z extends zCore.$ZodType, IsOptional extends "required" | "optional"> = IsUnknownOrAny<Z> extends true ? GenericValidator : Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> ? VRequired<ConvexValidatorFromZodOutput<Inner, "required">> : Z extends zCore.$ZodPipe<infer _Input extends zCore.$ZodType, infer Output extends zCore.$ZodType> ? ConvexValidatorFromZodOutput<Output, IsOptional> : Z extends zCore.$ZodOptional<infer Inner extends zCore.$ZodType> ? VOptional<ConvexValidatorFromZodOutput<Inner, "optional">> : Z extends zCore.$ZodNullable<infer Inner extends zCore.$ZodType> ? ConvexValidatorFromZodOutput<Inner, IsOptional> extends Validator<any, "optional", any> ? VUnion<ConvexValidatorFromZodOutput<Inner, IsOptional>["type"] | null | undefined, [VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>, VNull], "optional", ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]> : VUnion<ConvexValidatorFromZodOutput<Inner, IsOptional>["type"] | null, [VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>, VNull], IsOptional, ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]> : ConvexValidatorFromZodCommon<Z, IsOptional>;

Key Changes Summary

  1. ConvexValidatorFromZodConvexValidatorFromZodOutput: Changed all recursive calls within ConvexValidatorFromZodOutput to use itself instead of ConvexValidatorFromZod

  2. Added $ZodOptional handling: Added explicit handling for z.optional() before falling through to ConvexValidatorFromZodCommon

  3. Added $ZodNullable handling: Added explicit handling for z.nullable() before falling through to ConvexValidatorFromZodCommon

This ensures that when processing optional/nullable wrapped codecs, the type system correctly uses the output schema of the codec (e.g., number) rather than the input schema (e.g., Date).


Testing

After applying the patch, the following should compile without errors:

import z from "zod/v4";
import { zodOutputToConvex } from "convex-helpers/server/zod4";

const dateToMsCodec = z.codec(z.date(), z.int().min(0), {
  encode: (millis: number) => new Date(millis),
  decode: (date: Date) => date.getTime(),
});

const optionalCodec = dateToMsCodec.optional();
const validator = zodOutputToConvex(optionalCodec);

// These should work without "Property does not exist on type 'never'" errors
console.log(validator.kind);        // "float64"
console.log(validator.isOptional);  // "optional"

The following applies to third-party contributors.
Convex employees and contractors can delete or ignore.
-->


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced handling of optional and nullable schema types in output validators for improved type consistency.
    • Improved type inference behavior across Zod validation transformations.
    • Refined compile-time validation for complex schema scenarios.

✏️ Tip: You can customize this high-level summary in your review settings.

# convex-helpers Zod4 Optional Codec Fix

## Problem

When using `zodOutputToConvex()` with an optional codec (e.g., `dateToMsCodec.optional()`), TypeScript incorrectly infers the type as `never` instead of `VOptional<VFloat64>`.

This happens because `ConvexValidatorFromZodOutput` uses `ConvexValidatorFromZod` (which looks at **input** types) for nested types like `$ZodOptional`, instead of using itself recursively (which looks at **output** types).

For codecs, the input type is something like `z.date()` which is not a valid Convex type, causing the type inference to fail.

## Files to Patch

- `node_modules/convex-helpers/server/zod4.ts`
- `node_modules/convex-helpers/server/zod4.d.ts`

---

## Patch for `zod4.ts`

### Location
Around line 1028-1048 (the `ConvexValidatorFromZodOutput` type definition)

### BEFORE (Original)

```typescript
/**
 * Return type of {@link zodOutputToConvex}.
 */
export type ConvexValidatorFromZodOutput<
  Z extends zCore.$ZodType,
  IsOptional extends "required" | "optional",
> =
  // `unknown` / `any`: we can't infer a precise return type at compile time
  IsUnknownOrAny<Z> extends true
    ? GenericValidator
    : // z.default()
      Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> // output: always there
      ? VRequired<ConvexValidatorFromZod<Inner, "required">>
      : // z.pipe()
        Z extends zCore.$ZodPipe<
            infer _Input extends zCore.$ZodType,
            infer Output extends zCore.$ZodType
          >
        ? ConvexValidatorFromZod<Output, IsOptional>
        : // All other schemas have the same input/output types
          ConvexValidatorFromZodCommon<Z, IsOptional>;
```

### AFTER (Patched)

```typescript
/**
 * Return type of {@link zodOutputToConvex}.
 */
export type ConvexValidatorFromZodOutput<
  Z extends zCore.$ZodType,
  IsOptional extends "required" | "optional",
> =
  // `unknown` / `any`: we can't infer a precise return type at compile time
  IsUnknownOrAny<Z> extends true
    ? GenericValidator
    : // z.default()
      Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> // output: always there
      ? VRequired<ConvexValidatorFromZodOutput<Inner, "required">>
      : // z.pipe() - use output schema for zodOutputToConvex
        Z extends zCore.$ZodPipe<
            infer _Input extends zCore.$ZodType,
            infer Output extends zCore.$ZodType
          >
        ? ConvexValidatorFromZodOutput<Output, IsOptional>
        : // z.optional() - handle here to use output types consistently
          Z extends zCore.$ZodOptional<infer Inner extends zCore.$ZodType>
          ? VOptional<ConvexValidatorFromZodOutput<Inner, "optional">>
          : // z.nullable() - handle here to use output types consistently
            Z extends zCore.$ZodNullable<infer Inner extends zCore.$ZodType>
            ? ConvexValidatorFromZodOutput<Inner, IsOptional> extends Validator<
                any,
                "optional",
                any
              >
              ? VUnion<
                  | ConvexValidatorFromZodOutput<Inner, IsOptional>["type"]
                  | null
                  | undefined,
                  [
                    VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>,
                    VNull,
                  ],
                  "optional",
                  ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]
                >
              : VUnion<
                  | ConvexValidatorFromZodOutput<Inner, IsOptional>["type"]
                  | null,
                  [
                    VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>,
                    VNull,
                  ],
                  IsOptional,
                  ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]
                >
            : // All other schemas have the same input/output types
              ConvexValidatorFromZodCommon<Z, IsOptional>;
```

---

## Patch for `zod4.d.ts`

### Location
Line 464 (the `ConvexValidatorFromZodOutput` type declaration - single long line)

### BEFORE (Original)

```typescript
export type ConvexValidatorFromZodOutput<Z extends zCore.$ZodType, IsOptional extends "required" | "optional"> = IsUnknownOrAny<Z> extends true ? GenericValidator : Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> ? VRequired<ConvexValidatorFromZod<Inner, "required">> : Z extends zCore.$ZodPipe<infer _Input extends zCore.$ZodType, infer Output extends zCore.$ZodType> ? ConvexValidatorFromZod<Output, IsOptional> : ConvexValidatorFromZodCommon<Z, IsOptional>;
```

### AFTER (Patched)

```typescript
export type ConvexValidatorFromZodOutput<Z extends zCore.$ZodType, IsOptional extends "required" | "optional"> = IsUnknownOrAny<Z> extends true ? GenericValidator : Z extends zCore.$ZodDefault<infer Inner extends zCore.$ZodType> ? VRequired<ConvexValidatorFromZodOutput<Inner, "required">> : Z extends zCore.$ZodPipe<infer _Input extends zCore.$ZodType, infer Output extends zCore.$ZodType> ? ConvexValidatorFromZodOutput<Output, IsOptional> : Z extends zCore.$ZodOptional<infer Inner extends zCore.$ZodType> ? VOptional<ConvexValidatorFromZodOutput<Inner, "optional">> : Z extends zCore.$ZodNullable<infer Inner extends zCore.$ZodType> ? ConvexValidatorFromZodOutput<Inner, IsOptional> extends Validator<any, "optional", any> ? VUnion<ConvexValidatorFromZodOutput<Inner, IsOptional>["type"] | null | undefined, [VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>, VNull], "optional", ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]> : VUnion<ConvexValidatorFromZodOutput<Inner, IsOptional>["type"] | null, [VRequired<ConvexValidatorFromZodOutput<Inner, IsOptional>>, VNull], IsOptional, ConvexValidatorFromZodOutput<Inner, IsOptional>["fieldPaths"]> : ConvexValidatorFromZodCommon<Z, IsOptional>;
```

---

## Key Changes Summary

1. **`ConvexValidatorFromZod` → `ConvexValidatorFromZodOutput`**: Changed all recursive calls within `ConvexValidatorFromZodOutput` to use itself instead of `ConvexValidatorFromZod`

2. **Added `$ZodOptional` handling**: Added explicit handling for `z.optional()` before falling through to `ConvexValidatorFromZodCommon`

3. **Added `$ZodNullable` handling**: Added explicit handling for `z.nullable()` before falling through to `ConvexValidatorFromZodCommon`

This ensures that when processing optional/nullable wrapped codecs, the type system correctly uses the **output** schema of the codec (e.g., `number`) rather than the **input** schema (e.g., `Date`).

---

## Testing

After applying the patch, the following should compile without errors:

```typescript
import z from "zod/v4";
import { zodOutputToConvex } from "convex-helpers/server/zod4";

const dateToMsCodec = z.codec(z.date(), z.int().min(0), {
  encode: (millis: number) => new Date(millis),
  decode: (date: Date) => date.getTime(),
});

const optionalCodec = dateToMsCodec.optional();
const validator = zodOutputToConvex(optionalCodec);

// These should work without "Property does not exist on type 'never'" errors
console.log(validator.kind);        // "float64"
console.log(validator.isOptional);  // "optional"
```
@coderabbitai
Copy link

coderabbitai bot commented Dec 23, 2025

Walkthrough

Updated the Zod-to-Convex output validator mapping to use consistent output semantics. Modified ConvexValidatorFromZodOutput type branches to route through output-oriented logic for pipes, explicitly handle optional and nullable transformations using VOptional and VUnion constructs, and maintained fallback to ConvexValidatorFromZodCommon.

Changes

Cohort / File(s) Summary
Output Validator Type Mapping
packages/convex-helpers/server/zod4.ts
Modified ConvexValidatorFromZodOutput type definition: default branch now uses ConvexValidatorFromZodOutput<Inner, "required"> instead of ConvexValidatorFromZod; pipe branch routes through ConvexValidatorFromZodOutput<Output, IsOptional>; added explicit handling for zodOptional and zodNullable using VOptional and VUnion combinations; fallback to ConvexValidatorFromZodCommon.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: optional transforms for zod4 output' directly and concisely summarizes the main change: fixing optional/nullable transform handling in the Zod-to-Convex output validator mapping.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 31b9ac3 and 3ec5587.

📒 Files selected for processing (1)
  • packages/convex-helpers/server/zod4.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

When modifying complex TypeScript types, run npm run typecheck in the repository root to ensure types are correct, rather than running tsc manually

Files:

  • packages/convex-helpers/server/zod4.ts
🧬 Code graph analysis (1)
packages/convex-helpers/server/zod4.ts (3)
packages/convex-helpers/validators.ts (1)
  • VRequired (915-978)
packages/convex-helpers/server/zod3.ts (1)
  • ConvexValidatorFromZodOutput (1070-1265)
packages/convex-helpers/server/zod.ts (1)
  • ConvexValidatorFromZodOutput (171-172)
🔇 Additional comments (1)
packages/convex-helpers/server/zod4.ts (1)

1035-1079: LGTM! Correctly routes through output-focused logic for recursive type resolution.

The changes properly fix the TypeScript inference issue by ensuring ConvexValidatorFromZodOutput uses itself for recursive calls instead of delegating to ConvexValidatorFromZod. Key points:

  1. Default handling (line 1040): Using VRequired is correct since defaults guarantee a value in the output.
  2. Pipe handling (lines 1041-1046): Correctly uses the output schema (Output) for output-focused conversion.
  3. Optional/Nullable handling (lines 1047-1077): Explicitly handles these within the output path, ensuring codecs (where input types like z.date() aren't valid Convex types) don't collapse to never.

The nullable union logic correctly handles both optional and required inner types, matching the pattern in ConvexValidatorFromZodCommon.

As per the coding guidelines, consider running npm run typecheck to verify the types are correct after this change.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant