Skip to content

.readonly() freezes the input on a failed parse, and safeParse throws for typed arrays #6745

Open

Description

@trackoor

$ZodReadonly calls Object.freeze(payload.value) even when the inner schema failed, so safeParse freezes the caller's rejected object, and throws instead of returning an error when the rejected value is a non-empty typed array or Buffer.

import { z } from "zod";

const input = { x: 1 };
const res = z.string().readonly().safeParse(input);
console.log(res.success, Object.isFrozen(input));

z.array(z.number()).readonly().safeParse(new Uint8Array(1));

node t.mjs

Actual:

false true
file:///.../node_modules/zod/v4/core/schemas.js:2404
        payload.value = Object.freeze(payload.value);
                               ^

TypeError: Cannot freeze array buffer views with elements

Expected: false false, and the second call returns { success: false, error: ... } without throwing.

Tested with zod 4.6.7 and main 19c40d2 (bundled from source), Node.js v26.13.0.

handleReadonlyResult in packages/zod/src/v4/core/schemas.ts freezes whenever !payload.memo; it could skip the freeze when payload.issues.length > 0.

Credit: https://ucsbplse.github.io/

Activity

  1. hamarshehmhmd commented on Nov 7, 2026

    @hamarshehmhmd

    Confirmed on main (19c40d2). Skipping the freeze on failure, like you suggested, fixes both cases:

     function handleReadonlyResult(payload: ParsePayload): ParsePayload {
       // A repeat visit hands back a node that is still being built; freezing it here would make the rest of its keys fail to assign.
    -  if (!payload.memo) payload.value = Object.freeze(payload.value);
    +  // A failed parse hands back the caller's input, which must not be frozen (and typed arrays cannot be).
    +  if (!payload.memo && payload.issues.length === 0) payload.value = Object.freeze(payload.value);
       return payload;
     }

    Before → after:

    string().readonly() on object:     false, input frozen: true   →  false, input frozen: false
    array().readonly() on Uint8Array:  THROWS "Cannot freeze..."   →  success false, invalid_type
    success path still frozen:         true                        →  true
    

    The async path (.refine(async ...) + safeParseAsync) also leaves the input unfrozen, since it goes through the same helper.

    Regression test added to packages/zod/src/v4/classic/tests/readonly.test.ts. It covers sync object, async object and Uint8Array, and fails on main in both the zod and compile-mode projects.

    • nub run test packages/zod/src/v4: 411 files, 6799 tests passed, no type errors

    Happy to open a PR.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions