Skip to content

chore!: unify argument validation to zod - #3935

Merged
B4nan merged 25 commits into
v4from
chore/unify-to-zod
Aug 12, 2026
Merged

chore!: unify argument validation to zod#3935
B4nan merged 25 commits into
v4from
chore/unify-to-zod

Conversation

@vladfrangu

@vladfrangu vladfrangu commented Jul 30, 2026

Copy link
Copy Markdown
Member

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:

// 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 refactor!: validate arguments with zod instead of ow apify-client-js#986 — a follow-up may extract them into a shared package.

@vladfrangu
vladfrangu changed the base branch from master to v4 July 30, 2026 11:36
@vladfrangu
vladfrangu force-pushed the chore/unify-to-zod branch 2 times, most recently from 48101f6 to acb4aab Compare July 30, 2026 13:10
@vladfrangu vladfrangu added the t-tooling Issues with this label are in the ownership of the tooling team. label Aug 4, 2026
@vladfrangu
vladfrangu force-pushed the chore/unify-to-zod branch 2 times, most recently from e9dee4f to 5d71608 Compare August 11, 2026 11:41
@vladfrangu
vladfrangu marked this pull request as ready for review August 11, 2026 13:35
…gument results

Message format matches #3716: the zod issue message plus the offending
field path and the received value. parseArgument drops the label
argument and returns the parsed (default-applied) value, typed as the
input intersected with the schema output.
…nce validators

Options are now destructured from the parse result with defaults
declared in the schemas, all per-call schemas are hoisted to module
scope, and instance-like options are validated with specific
validators (instanceof for classes, objectWithKeys for interfaces)
instead of anyObject.
Crawler and launcher classes build their strict options schema once as
a static (optionsSchema) and consume the parse result. Adds missing
optionsShape entries (requestManager, ignoreIframes, ignoreShadowRoots)
and makes LazyDefaultHttpClient extend BaseHttpClient so it passes the
new instanceof validation.
Consumers were mixing the class from @crawlee/http-client with the
same-named interface from @crawlee/types, producing aliased duplicate
imports (BaseHttpClient_2) in the public API reports. Since httpClient
options are now validated with instanceof, the class is the actual
contract — import it everywhere. Only the definition site keeps the
interface (the class implements it).
…tionError

Matches the formatter in apify-client-js#986 - a union's own message is a
bare "Invalid input"; the offending arm and field only appear in the
nested errors.
- validate Request.state with z.enum(RequestState), so SKIPPED (7) is accepted
- hoist the shared urlPatternSchema into enqueue_links/shared.ts (was duplicated 4x)
- move stranded imports above the schema consts in concurrency_system.ts
- use safeParse instead of parse + try/catch for schema defaults in Configuration
- inline catalog versions in optionalDependencies too when publishing
- parseArgument/ArgumentValidationError accept an optional label naming
  the validated interface, appended to every error line the way ow's
  errors ended with "in object `HttpCrawlerOptions`"; wired up at all
  option-bag call sites (crawlers, launchers, enqueueLinks, storages)
- bare custom-schema failures now name the received type before the
  value ("expected number, received string at `x`, got `3`"), skipped
  when the two would read the same (NaN)
- new Request(...) rejects a bare URL string with a hint pointing at
  the `{ url }` object form, and labels its errors with RequestOptions
"expected number, received the string `3` at `maxRequestRetries`"
instead of a dangling ", got `3`" after the location. Messages that
never name a received type (regex, min/max, enums) keep the plain
suffix; NaN is named as itself and an empty string is made visible.
`schemas.arrayOf(item, 'numbers')` reports a top-level type miss as
"expected an array of numbers, received the number `500`" instead of
zod's bare "expected array"; per-element messages are unchanged.
Adopted at every element-typed array option (status codes, urls,
include/exclude patterns, mime types, domains).
… errors

Also drop dead launchContext destructure defaults — BrowserCrawler's schema
already defaults it to an empty object.

@barjin barjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @vladfrangu !

The diff looks like what I'd expect it to look like, I don't see much else to review 😅 If I understood correctly, this went through rounds of LLM reviews, right? Since the CI is green, I say let's merge and see what feedback we get. The longer we wait, the more conflicts there will be.

launcher: schemas.anyObject.optional(),
};

protected static override optionsSchema = z.strictObject(PlaywrightCrawler.optionsShape);

@barjin barjin Aug 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to my findings on another issue (#3549 ), this will run during the import, which can add ~50ms to the @crawlee/playwright import (and other packages that use this pattern). Perhaps we can lazy-initialize this?

By no means is this something we have to solve now, though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make an issue to track this and assign it to me please 🙏

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

your wish is my command #4025

@janbuchar janbuchar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What @barjin said

@B4nan B4nan changed the title chore: unify argument validation to zod chore!: unify argument validation to zod Aug 12, 2026
@B4nan
B4nan merged commit 6f4a5ca into v4 Aug 12, 2026
8 checks passed
@B4nan
B4nan deleted the chore/unify-to-zod branch August 12, 2026 14:59
B4nan added a commit 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>
B4nan added a commit that referenced this pull request Aug 12, 2026
…atalog dependency

Two upstream v4 changes landed after the last E2E run and broke the suite:

- the enqueueLinks split (#4010) changed the context helper's return value to
  the addRequestsBatched result, so the *-enqueue-links fixtures now assert on
  `addedRequests` being empty instead of deep-equality with the old shape
- the zod validation unification (#3935) introduced a `catalog:` dependency,
  which npm cannot resolve when the platform builds the actor image; the E2E
  package-copy step now rewrites catalog deps to their pinned versions from
  pnpm-workspace.yaml, the same way it already rewrites `workspace:` deps
B4nan added a commit 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>
B4nan added a commit that referenced this pull request Aug 18, 2026
…atalog dependency

Two upstream v4 changes landed after the last E2E run and broke the suite:

- the enqueueLinks split (#4010) changed the context helper's return value to
  the addRequestsBatched result, so the *-enqueue-links fixtures now assert on
  `addedRequests` being empty instead of deep-equality with the old shape
- the zod validation unification (#3935) introduced a `catalog:` dependency,
  which npm cannot resolve when the platform builds the actor image; the E2E
  package-copy step now rewrites catalog deps to their pinned versions from
  pnpm-workspace.yaml, the same way it already rewrites `workspace:` deps
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

5 participants