Skip to content

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

Open
vdusek wants to merge 29 commits into
v3from
feat/replace-ow-with-zod
Open

refactor!: validate arguments with zod instead of ow#986
vdusek wants to merge 29 commits into
v3from
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/32276952895#summary-96146513823

@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
Raises the minimum supported Node.js version to 22, matching the Crawlee
v4 floor. `apify-client` sits below `@crawlee/core`, so its minimum must
not be higher than Crawlee's, and there is no reason for it to be lower.

- **`package.json`** — added `engines: { "node": ">=22.0.0" }`.
- **`check.yaml`** — `build_and_test` matrix `[20, 22, 24]` -> `[22, 24,
26]`, so the client is now also tested against Node.js 26. The remaining
jobs stay on 24, as Node.js 26 does not become LTS until October 2026.
- **`tsconfig.json`** — dropped the `module: "Node16"` and
`moduleResolution` overrides, so the `@apify/tsconfig` base values
(`node20`/`nodenext`) apply; `lib` -> `["ES2024", "DOM"]`, `target:
"ES2024"`. ES2024 rather than Crawlee's `ESNext`, because Node 22 does
not implement ES2025 syntax such as `using`.
- **Docs** — updated `docs/01_introduction/index.md` and
`CONTRIBUTING.md`. `website/versioned_docs/version-2/**` is untouched,
as it is a frozen v2 snapshot.

Drive-by: `pnpm tsc-check-tests` was already failing on `v3`, mostly
with `TS1541` from the old `module: Node16`. Nothing ran it in CI, so it
went unnoticed; it is fixed and now part of the `lint` job.

BREAKING CHANGE: Node.js 18 and 20 are no longer supported. The minimum
supported version is now Node.js 22.

*✍️ Drafted by Claude Code*

---------

Co-authored-by: Martin Adámek <banan23@gmail.com>
@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 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 9 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.
@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
JSON.stringify(fn) returns undefined instead of throwing, so a function
value would silently PUT an empty request body.
vdusek added a commit that referenced this pull request Aug 18, 2026
Port of the integration test suite from the Python API client.

Adds an integration test suite that executes the client against the live
Apify API. This is the same approach as in the Python API client: real
API calls with a test user token, resources created under unique names
and cleaned up afterwards, and eventual consistency handled by polling
helpers rather than sleeps or retries.

Merge this **before** #985, #986 and the rest of the v3 work, so those
changes have some end-to-end test coverage.

## Coverage

196 tests over Actors, Actor versions, Actor env vars, builds, runs,
logs, tasks, schedules, webhooks, webhook dispatches, datasets,
key-value stores, request queues, the store, and users.

## Test tiers

`vitest.config.mts` now defines two projects, so the existing unit tests
stay fast and offline:

- `pnpm test` - unit tests only (unchanged behavior)
- `pnpm test:integration` - integration tier only
- `pnpm test:all` - both

## Credentials

The tier needs `APIFY_TEST_USER_API_TOKEN`.

## CI

A new `integration_tests` job in `check.yaml` runs the tier on Node 26.
It is skipped for fork PRs, where repository secrets are unavailable,
and can be triggered on demand via `workflow_dispatch`.

*✍️ Drafted by Claude Code*
Base automatically changed from test/integration-suite to v3 August 18, 2026 16:15
B4nan added a commit to apify/crawlee that referenced this pull request Aug 18, 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 added a commit that referenced this pull request Aug 19, 2026
Port of the integration test suite from the Python API client.

Adds an integration test suite that executes the client against the live
Apify API. This is the same approach as in the Python API client: real
API calls with a test user token, resources created under unique names
and cleaned up afterwards, and eventual consistency handled by polling
helpers rather than sleeps or retries.

Merge this **before** #985, #986 and the rest of the v3 work, so those
changes have some end-to-end test coverage.

196 tests over Actors, Actor versions, Actor env vars, builds, runs,
logs, tasks, schedules, webhooks, webhook dispatches, datasets,
key-value stores, request queues, the store, and users.

`vitest.config.mts` now defines two projects, so the existing unit tests
stay fast and offline:

- `pnpm test` - unit tests only (unchanged behavior)
- `pnpm test:integration` - integration tier only
- `pnpm test:all` - both

The tier needs `APIFY_TEST_USER_API_TOKEN`.

A new `integration_tests` job in `check.yaml` runs the tier on Node 26.
It is skipped for fork PRs, where repository secrets are unavailable,
and can be triggered on demand via `workflow_dispatch`.

*✍️ Drafted by Claude Code*
vdusek added 2 commits August 19, 2026 12:51
# Conflicts:
#	CONTRIBUTING.md
#	src/resource_clients/dataset.ts
#	src/resource_clients/key_value_store.ts
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