Skip to content

refactor!: validate arguments with zod instead of ow - #986

Open
vdusek wants to merge 22 commits into
test/integration-suitefrom
feat/replace-ow-with-zod
Open

refactor!: validate arguments with zod instead of ow#986
vdusek wants to merge 22 commits into
test/integration-suitefrom
feat/replace-ow-with-zod

Conversation

@vdusek

@vdusek vdusek commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Replaces ow with zod for runtime argument validation. Input validation only — response validation is a separate PR.

How it works

  • ArgumentValidationError and validate() live in this package: apify-client sits below @crawlee/core and the SDK in the dependency graph, so it cannot import theirs.
  • Schemas use z.strictObject / z.looseObject / z.enum, not the deprecated .strict() / .passthrough() / z.nativeEnum().
  • chunkSize now works on every paginating list() whose schema spreads the shared paginationOptionsShape. It used to type-check but throw; ow's exactShape had the same gap.

Browser bundle

  • Tree-shaking and minification are back on in rsbuild.config.ts, off since the webpack-to-rsbuild migration in chore: update eslint, adopt prettier and rsbuild #671. Now 288 kB raw / 87 kB gzip, from 1439 kB / 273 kB — below the 946 kB / 203 kB before this PR. A 320 kB budget fails the build, so it cannot grow unnoticed again.

Breaking changes

  • Invalid arguments throw ArgumentValidationError (exported from apify-client), not ow's ArgumentError — different messages, the zod issues on issues, the original ZodError on cause. A message renders at most 10 problems, then ... and N more.
  • Arrays and functions no longer pass where a plain object is expected: update() / create() fields, TaskClient.start() / call() input, the storage schema option, DatasetClient.pushItems() items, RequestQueueClient.addRequest() / batchAddRequests() requests.
  • Infinity no longer passes on numeric options such as waitSecs, timeout or memory, and an invalid Date no longer passes on startedBefore / startedAfterz.number() requires a finite number and z.date() a valid date, where ow only checked the type.
  • KeyValueStoreClient.setRecord() rejects Infinity as a record value, which v2 accepted and stored as null.
  • Options that were declared but always rejected at runtime are gone from the types: chunkSize on downloadItems() and createItemsPublicUrl(), signature on createItemsPublicUrl() and createKeysPublicUrl() — a compile error now instead of a throw.
  • Date, Map, Set and other class instances still pass as objects, as under ow.

✍️ Drafted by Claude Code

@vdusek vdusek added adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team. labels Jul 30, 2026
@vdusek vdusek self-assigned this Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

⚠️ There are broken links in the documentation.

See more at https://github.com/apify/apify-client-js/actions/runs/32120638897#summary-95660035693

@vdusek vdusek changed the title refactor!: replace ow with zod for argument validation refactor!: validate arguments with zod instead of ow Jul 30, 2026
@vdusek
vdusek requested a review from B4nan July 31, 2026 09:40
@vdusek
vdusek marked this pull request as ready for review July 31, 2026 09:40
@vdusek
vdusek requested a review from szaganek as a code owner July 31, 2026 09:40
@vdusek
vdusek force-pushed the feat/replace-ow-with-zod branch from 4e8b5c1 to b74a66e Compare August 3, 2026 13:48
@B4nan

B4nan commented Aug 3, 2026

Copy link
Copy Markdown
Member

Follow-ups: the browser bundle grows from 946 kB to 1446 kB raw (203 kB -> 272 kB gzip), because rsbuild.config.ts disables tree-shaking and minification; .strict(), .passthrough() and z.nativeEnum() are deprecated in zod 4 and could move to z.strictObject / z.looseObject / z.enum; chunkSize is missing from every .strict() list schema (pre-existing — ow's exactShape had the same gap).

I would rather fix it here before it gets merged. We don't want to use any deprecated methods, and this PR introduces the bundle size issue.

@vdusek

vdusek commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

I would rather fix it here before it gets merged. We don't want to use any deprecated methods, and this PR introduces the bundle size issue.

OK, I'll check it out

@vdusek
vdusek force-pushed the feat/replace-ow-with-zod branch from aa2684d to 41d769b Compare August 4, 2026 08:50
@vdusek

vdusek commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@B4nan it's ready for a re-check

@B4nan
B4nan requested a review from vladfrangu August 4, 2026 09:48
B4nan added a commit to apify/crawlee that referenced this pull request Aug 12, 2026
Replaces the remaining `ow`-based argument validation with `zod` across
all packages and reworks how validation results are consumed and
reported.

Closes #3716

## What changed

- **`ow` is gone** — every argument check now goes through
`parseArgument(value, schema, label?)` from `@crawlee/utils`, backed by
shared zod schemas (`schemas`, exported via `@crawlee/utils/internal`).
The `@sapphire/shapeshift` checks in `@crawlee/fs-storage` were
converted too, so a single validation library remains.
- **Parse results are used everywhere** — option defaults moved from
destructuring into the schemas (`.default(...)`), and call sites
destructure the typed parse result. `parseArgument` returns `TValue &
z.output<TSchema>`, so call sites keep their declared TS types while
gaining the defaults.
- **Schemas are built once** — all per-call schemas are hoisted to
module scope; crawler/launcher classes build their strict options schema
once as a `static optionsSchema` next to `optionsShape`. The
`urlPatternSchema` for `include`/`exclude` lives in
`enqueue_links/shared.ts`, next to the type it validates.
- **Specific validators instead of `anyObject`** — class-typed options
use `z.instanceof(...)` (`BaseHttpClient`, `Configuration`,
`EventManager`), interface-typed ones use duck-typed `objectWithKeys`
validators (`storageBackend`, `requestManager`, `logger`, …), and
element-typed arrays use the new `schemas.arrayOf(item, 'numbers')`.

## Error messages

`ArgumentValidationError` (replacing ow's `ArgumentError`) renders one
line per issue: the expected type, the received type and value folded
into one clause, the offending field path, and the validated interface:

```text
// v3 (ow) — first issue only
Expected property `maxRequestRetries` to be of type `number` but received type `string` in object `HttpCrawlerOptions`

// v4 (zod) — every issue, one line each
Invalid input: expected number, received the string `many` at `maxRequestRetries` in `HttpCrawlerOptions`
Invalid input: expected an array of numbers, received the number `500` at `additionalHttpErrorStatusCodes` in `HttpCrawlerOptions`
Invalid input: expected boolean, received the string `yes` at `retryOnBlocked` in `HttpCrawlerOptions`
```

Details worth knowing:

- Union failures expand into one line per failed arm (zod's own message
is a bare "Invalid input").
- `NaN` is named as itself, an empty string renders as `''`, and arrays
name their element type (``expected an array of URL patterns``) — none
of which ow or stock zod reported.
- `new Request('https://…')` gets a targeted hint pointing at the `{ url
}` object form.
- For programmatic handling, the error exposes zod's structured output:
`error.issues` and the raw `ZodError` as a typed `cause`.

The migration is documented in the v4 upgrading guide
(`docs/upgrading/upgrading_v4.md`), including a rename-cheat-sheet
entry.

## Notes

- Custom HTTP clients must now **extend `BaseHttpClient`** from
`@crawlee/http-client` rather than just implementing the interface (all
shipped clients already do; `LazyDefaultHttpClient` was converted). Same
applies to test mocks — `Object.create(BaseHttpClient.prototype)` works.
- One caveat of consuming parse results: zod object schemas return a
pruned plain copy, so options holding class instances are validated with
passthrough schemas (`z.custom`-based) to keep their prototypes — there
are comments at the relevant schemas.
- Fixes a few latent gaps surfaced along the way: `Request.state` now
accepts `RequestState.SKIPPED` (validated via `z.enum(RequestState)`),
and the publish-time catalog inlining covers `optionalDependencies`.
- `ArgumentValidationError` and its formatter are intentionally kept
close to the copy in apify/apify-client-js#986 — a follow-up may extract
them into a shared package.

---------

Co-authored-by: Martin Adámek <banan23@gmail.com>
B4nan added a commit to apify/crawlee that referenced this pull request Aug 12, 2026
Replaces the remaining `ow`-based argument validation with `zod` across
all packages and reworks how validation results are consumed and
reported.

Closes #3716

- **`ow` is gone** — every argument check now goes through
`parseArgument(value, schema, label?)` from `@crawlee/utils`, backed by
shared zod schemas (`schemas`, exported via `@crawlee/utils/internal`).
The `@sapphire/shapeshift` checks in `@crawlee/fs-storage` were
converted too, so a single validation library remains.
- **Parse results are used everywhere** — option defaults moved from
destructuring into the schemas (`.default(...)`), and call sites
destructure the typed parse result. `parseArgument` returns `TValue &
z.output<TSchema>`, so call sites keep their declared TS types while
gaining the defaults.
- **Schemas are built once** — all per-call schemas are hoisted to
module scope; crawler/launcher classes build their strict options schema
once as a `static optionsSchema` next to `optionsShape`. The
`urlPatternSchema` for `include`/`exclude` lives in
`enqueue_links/shared.ts`, next to the type it validates.
- **Specific validators instead of `anyObject`** — class-typed options
use `z.instanceof(...)` (`BaseHttpClient`, `Configuration`,
`EventManager`), interface-typed ones use duck-typed `objectWithKeys`
validators (`storageBackend`, `requestManager`, `logger`, …), and
element-typed arrays use the new `schemas.arrayOf(item, 'numbers')`.

`ArgumentValidationError` (replacing ow's `ArgumentError`) renders one
line per issue: the expected type, the received type and value folded
into one clause, the offending field path, and the validated interface:

```text
// v3 (ow) — first issue only
Expected property `maxRequestRetries` to be of type `number` but received type `string` in object `HttpCrawlerOptions`

// v4 (zod) — every issue, one line each
Invalid input: expected number, received the string `many` at `maxRequestRetries` in `HttpCrawlerOptions`
Invalid input: expected an array of numbers, received the number `500` at `additionalHttpErrorStatusCodes` in `HttpCrawlerOptions`
Invalid input: expected boolean, received the string `yes` at `retryOnBlocked` in `HttpCrawlerOptions`
```

Details worth knowing:

- Union failures expand into one line per failed arm (zod's own message
is a bare "Invalid input").
- `NaN` is named as itself, an empty string renders as `''`, and arrays
name their element type (``expected an array of URL patterns``) — none
of which ow or stock zod reported.
- `new Request('https://…')` gets a targeted hint pointing at the `{ url
}` object form.
- For programmatic handling, the error exposes zod's structured output:
`error.issues` and the raw `ZodError` as a typed `cause`.

The migration is documented in the v4 upgrading guide
(`docs/upgrading/upgrading_v4.md`), including a rename-cheat-sheet
entry.

- Custom HTTP clients must now **extend `BaseHttpClient`** from
`@crawlee/http-client` rather than just implementing the interface (all
shipped clients already do; `LazyDefaultHttpClient` was converted). Same
applies to test mocks — `Object.create(BaseHttpClient.prototype)` works.
- One caveat of consuming parse results: zod object schemas return a
pruned plain copy, so options holding class instances are validated with
passthrough schemas (`z.custom`-based) to keep their prototypes — there
are comments at the relevant schemas.
- Fixes a few latent gaps surfaced along the way: `Request.state` now
accepts `RequestState.SKIPPED` (validated via `z.enum(RequestState)`),
and the publish-time catalog inlining covers `optionalDependencies`.
- `ArgumentValidationError` and its formatter are intentionally kept
close to the copy in apify/apify-client-js#986 — a follow-up may extract
them into a shared package.

---------

Co-authored-by: Martin Adámek <banan23@gmail.com>
vdusek and others added 11 commits August 18, 2026 09:37
BREAKING CHANGE: runtime argument validation switched from `ow` to `zod`, so
every invalid-argument error message changed, and the thrown error is now an
`ArgumentValidationError` (newly exported from `apify-client`) instead of `ow`'s
`ArgumentError`. It exposes the structured zod issues on `issues` and keeps the
original `ZodError` on `cause`, so you can branch on them instead of parsing the
message. Values that `ow.object` accepted only incidentally are now rejected:
arrays no longer pass as objects for `update()` / `create()` fields, for
`TaskClient.start()` / `call()` input, for the storage `schema` option, or as
`DatasetClient.pushItems()` array items (which must be objects or strings).
The ow-based validation rejected symbol and bigint values loudly, but the
zod replacement only checked for undefined. A symbol value would then pass
validation, serialize to undefined, and silently PUT an empty record body.
Also fixes a pre-existing "validatioon" typo carried through two comments.
`describeReceived('')` used to produce bare backticks with nothing
between them, e.g. for `client.actor('')`.
@vdusek
vdusek changed the base branch from v3 to test/integration-suite August 18, 2026 07:47
@vdusek
vdusek force-pushed the feat/replace-ow-with-zod branch from c9b3ae3 to 9f26108 Compare August 18, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants