Skip to content

Speed up assignProperties with an Object.assign fast path - #9511

Merged
tim-smart merged 9 commits into
Effect-TS:mainfrom
sledorze:assign-properties-fast-path
Oct 18, 2026
Merged

tim-smart merged 9 commits into
Effect-TS:mainfrom
sledorze:assign-properties-fast-path

Conversation

@sledorze

@sledorze sledorze commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

assignProperties (used by Data.Class/TaggedClass, Data.Error/TaggedError, Request.Class, Persistable and OpenApi field copying) loops over Reflect.ownKeys and assigns one property at a time, so that an own "__proto__" key lands as a data property instead of firing the inherited __proto__ setter.

When the source has no own "__proto__" key (Object.hasOwn), that loop is replaced with a single Object.assign, which for ordinary objects copies the same own enumerable string and symbol keys in the same order with the same [[Get]]/[[Set]] semantics, including throwing on frozen targets and stopping at the first throwing getter. Sources with an own "__proto__" key (e.g. JSON.parse output) keep using the per-property loop.

Micro-benchmark, 5-field class instance, 2,000,000 iterations per round, medians: ~206 ns/op → ~42 ns/op on Node 22.16 (author); ~2030 ns/op → ~330 ns/op on a slower Node 26 box (reviewer). Both ~5-6x.

Limitation: callers forward user-supplied objects, including Proxies. The guard adds a getOwnPropertyDescriptor("__proto__") lookup before key enumeration and copying, so Proxy traps can observe it, throw or mutate state. Proxies with stable, side-effect-free traps retain the copying behavior, but full Proxy behavioral equivalence is not guaranteed: inconsistent descriptor answers can let an own __proto__ key reach the inherited setter. This is an accepted compatibility tradeoff under the untrusted-data prototype-pollution threat model (such as parsed JSON), not protection against arbitrary in-process code.

Tests: Data.Class copies own enumerable string and symbol keys and skips non-enumerable ones; an own __proto__ key from JSON.parse lands as a data property without replacing the instance prototype (fails against a bare Object.assign).

Closes EFF-2450

sledorze and others added 2 commits October 18, 2026 21:20
assignProperties (used by Data.Class/TaggedClass, Data.Error/TaggedError,
Request, Persistable and OpenApi field copying) looped over
Reflect.ownKeys and assigned one property at a time so that an own
"__proto__" key landed as a safe data property instead of triggering the
inherited __proto__ setter. For the common case - no own "__proto__" key -
that loop can be replaced with a single native Object.assign call, which
copies the same own+enumerable keys in the same order with the same
[[Get]]/[[Set]] semantics.

Micro-benchmark (Node 22.16, 5x2,000,000 iterations, 5-field class
instance): old loop ~206 ns/op median vs fast path ~42 ns/op median,
~4.9x. A V8 %HaveSameMap check confirms both paths converge on the same
hidden class for the same field shape.

Adds a differential test suite in test/Data.test.ts comparing the fast
path against a reimplementation of the old loop across getters, symbols,
non-enumerable keys, frozen/setter/read-only targets, null-prototype and
JSON.parse'd own-"__proto__" sources, plus a documented, deliberately
narrow known limitation: a Proxy source whose getOwnPropertyDescriptor
trap answers inconsistently across calls can still defeat the guard,
since it queries the source twice (once here, once inside Object.assign).
This does not affect any real call site in this codebase, all of which
pass plain objects or JSON.parse output.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
@changeset-bot

changeset-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cef5fc9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete: one blocking prototype-safety regression confirmed. Validated the reproduction against the changed helper; the Vitest suite was not run because dependencies are not installed.

Reviewed at cf80af8: 1 blocking, 0 nits.

Disagree? Reply in the finding's thread, or a maintainer can comment /janitor to re-run or dismiss this review.

Comment thread packages/effect/src/internal/record.ts Resolved
Replace the differential matrix and the internal-module tests with two
Data.Class tests: own enumerable string/symbol keys are copied and
non-enumerable keys are skipped, and an own __proto__ key from JSON.parse
lands as a data property without replacing the instance prototype.
effect-janitor[bot]
effect-janitor Bot previously approved these changes Oct 18, 2026
Comment thread packages/effect/src/internal/record.ts Resolved
@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 39.10 KB 39.08 KB +0.02 KB (+0.04%)
basic.ts 7.02 KB 7.02 KB 0.00 KB (0.00%)
batching.ts 10.84 KB 10.81 KB +0.02 KB (+0.22%)
brand.ts 6.71 KB 6.71 KB 0.00 KB (0.00%)
cache.ts 11.33 KB 11.33 KB 0.00 KB (0.00%)
config.ts 22.54 KB 22.52 KB +0.02 KB (+0.07%)
differ.ts 21.62 KB 21.60 KB +0.01 KB (+0.06%)
http-client.ts 24.01 KB 23.99 KB +0.02 KB (+0.07%)
http-router.ts 35.65 KB 35.64 KB +0.01 KB (+0.04%)
logger.ts 11.68 KB 11.68 KB 0.00 KB (0.00%)
metric.ts 9.16 KB 9.16 KB 0.00 KB (0.00%)
optic.ts 6.93 KB 6.93 KB 0.00 KB (0.00%)
pubsub.ts 15.77 KB 15.77 KB 0.00 KB (0.00%)
queue.ts 12.59 KB 12.59 KB 0.00 KB (0.00%)
schedule.ts 11.77 KB 11.77 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.82 KB 13.81 KB +0.01 KB (+0.06%)
schema-binary.ts 40.58 KB 40.57 KB +0.01 KB (+0.03%)
schema-class.ts 21.33 KB 21.31 KB +0.01 KB (+0.07%)
schema-fromJsonSchemaDocument.ts 32.42 KB 32.38 KB +0.03 KB (+0.10%)
schema-representation-roundtrip.ts 27.60 KB 27.59 KB +0.01 KB (+0.05%)
schema-string-transformation.ts 15.07 KB 15.06 KB +0.01 KB (+0.05%)
schema-string.ts 12.08 KB 12.07 KB +0.01 KB (+0.10%)
schema-template-literal.ts 16.30 KB 16.29 KB +0.01 KB (+0.09%)
schema-toArbitrary.ts 38.62 KB 38.60 KB +0.01 KB (+0.04%)
schema-toCodeDocument.ts 25.68 KB 25.66 KB +0.02 KB (+0.06%)
schema-toCodecJson.ts 20.55 KB 20.54 KB +0.01 KB (+0.05%)
schema-toEquivalence.ts 20.74 KB 20.73 KB +0.01 KB (+0.04%)
schema-toFormatter.ts 20.85 KB 20.84 KB +0.01 KB (+0.03%)
schema-toJsonSchemaDocument.ts 25.48 KB 25.46 KB +0.01 KB (+0.06%)
schema-toRepresentation.ts 20.84 KB 20.83 KB +0.01 KB (+0.04%)
schema.ts 20.52 KB 20.50 KB +0.01 KB (+0.07%)
stm.ts 13.87 KB 13.87 KB 0.00 KB (0.00%)
stream.ts 10.12 KB 10.10 KB +0.02 KB (+0.21%)

effect-janitor[bot]
effect-janitor Bot previously approved these changes Oct 18, 2026
@tim-smart
tim-smart merged commit 5547315 into Effect-TS:main Oct 18, 2026
10 of 11 checks passed
brandhaug added a commit to brandhaug/b2b-saas-starter that referenced this pull request Oct 25, 2026
## pnpm-workspace.yaml (default)

## Dependency Updates

| Package | From | To | Type |
| --- | --- | --- | --- |
| `@effect/platform-node` | 4.0.5 | 4.0.6 | patch |
| `@tanstack/react-router` | 1.170.45 | 1.170.46 | patch |
| `@tanstack/react-start` | 1.169.3 | 1.169.4 | patch |
| `ai` | 7.0.148 | 7.0.149 | patch |
| `effect` | 4.0.5 | 4.0.6 | patch |
| `effectful-better-auth` | 1.0.7 | 1.0.8 | patch |

## Release Notes

<details>
<summary><b>@<!---->effect/platform-node</b> (4.0.5 → 4.0.6)</summary>

### Patch Changes

- [#9506](Effect-TS/effect#9506)
[`679f956`](Effect-TS/effect@679f956)
Thanks @<!---->tim-smart! - Use `Effect.fnUntraced` for internal
functions that returned `Effect.gen`.

- [#9262](Effect-TS/effect#9262)
[`51930c0`](Effect-TS/effect@51930c0)
Thanks @<!---->effect-bot! - Return a typed multipart parse error
instead of a defect when a multipart request is missing its boundary.

- [#9402](Effect-TS/effect#9402)
[`d1201f0`](Effect-TS/effect@d1201f0)
Thanks @<!---->effect-bot! - Release writables created by
`NodeSink.fromWritable` and `fromWritableChannel` when upstream fails or
the sink is interrupted, preserving the original exit cause. Writables
using `endOnDone: false` remain open. Preserve stream body encoding
errors in the Node HTTP client instead of reporting transport errors
caused by request cleanup.

- [#9575](Effect-TS/effect#9575)
[`c29ddd8`](Effect-TS/effect@c29ddd8)
Thanks @<!---->tim-smart! - Add streaming request payloads to `HttpApi`.
An endpoint can declare `HttpApiSchema.StreamUint8Array` or the new
`HttpApiSchema.StreamNdjson({ schema })` as its `payload`, and the
handler receives a lazy `Stream` that reads the body as it pulls. Bad
NDJSON lines fail the stream with `HttpApiSchemaError` (400). The new
`maxBytes` and `maxElementBytes` limits fail it with the new
`HttpApiPayloadTooLarge` (413), and a `content-length` over `maxBytes`
is rejected before the handler runs. `StreamNdjson` also works as a
success response.
  
`HttpApiClient` sends a `Stream` payload without buffering it and infers
the stream's requirements. `HttpApiTest` passes stream payloads through
unbuffered. `OpenApi.fromApi` marks streaming request bodies with `x-

…[full
notes](https://github.com/Effect-TS/effect/releases/tag/%40effect/platform-node%404.0.6)

</details>

<details>
<summary><b>@<!---->tanstack/react-router</b> (1.170.45 →
1.170.46)</summary>

### Patch Changes

- [#8670](TanStack/router#8670)
[`32a3360`](TanStack/router@32a3360)
- Reference agent skills in other packages by their `intent load`
identifier (`@tanstack/<package>#<skill>`) instead of relative file
links that break once packages are installed.

- [#8671](TanStack/router#8671)
[`689a7c5`](TanStack/router@689a7c5)
- Publish the `@tanstack/react-router` agent skills (`react-router`,
`compositions/router-query`, `lifecycle/migrate-from-react-router`),
which were missing from the package's `files` list.

- Updated dependencies
\[[`32a3360`](TanStack/router@32a3360)]:
    -   @<!---->tanstack/[email protected]

</details>

<details>
<summary><b>@<!---->tanstack/react-start</b> (1.169.3 →
1.169.4)</summary>

### Patch Changes

- [#8670](TanStack/router#8670)
[`32a3360`](TanStack/router@32a3360)
- Reference agent skills in other packages by their `intent load`
identifier (`@tanstack/<package>#<skill>`) instead of relative file
links that break once packages are installed.

- Updated dependencies
\[[`32a3360`](TanStack/router@32a3360),
[`689a7c5`](TanStack/router@689a7c5),
[`ed55775`](TanStack/router@ed55775),
[`63a886d`](TanStack/router@63a886d),
[`d9b07e8`](TanStack/router@d9b07e8)]:
    -   @<!---->tanstack/[email protected]
    -   @<!---->tanstack/[email protected]
    -   @<!---->tanstack/[email protected]
    -   @<!---->tanstack/[email protected]
    -   @<!---->tanstack/[email protected]
    -   @<!---->tanstack/[email protected]
    -   @<!---->tanstack/[email protected]

</details>

<details>
<summary><b>effect</b> (4.0.5 → 4.0.6)</summary>

### Patch Changes

- [#9674](Effect-TS/effect#9674)
[`ce13a48`](Effect-TS/effect@ce13a48)
Thanks @<!---->effect-bot! - Allow `Response.AllParts`, `Response.Part`
and `Response.StreamPart` to be called without a toolkit, decoding tool
call and tool result parts for any tool name with `unknown` parameters
and results.

- [#9696](Effect-TS/effect#9696)
[`f3bbbb4`](Effect-TS/effect@f3bbbb4)
Thanks @<!---->gcanti! - Fix `Array.unionWith` calling custom
equivalence callbacks with values from the wrong input or in the wrong
order.

- [#9511](Effect-TS/effect#9511)
[`5547315`](Effect-TS/effect@5547315)
Thanks @<!---->sledorze! - Speed up field copying in `Data.Class`,
`Data.Error`, `Request.Class` and `Persistable` constructors.

- [#9743](Effect-TS/effect#9743)
[`6093cfb`](Effect-TS/effect@6093cfb)
Thanks @<!---->effect-bot! - Fix `AsyncResult` equality treating
unrelated `Equal` values with a matching tag and hash as equal.

- [#9607](Effect-TS/effect#9607)
[`30319d5`](Effect-TS/effect@30319d5)
Thanks @<!---->effect-bot! - Fix Effect atoms dropping the latest
exposed success when an asynchronous run fails, so an `initialValue` or
a success published with `get.setSelf` remains available as the
failure's previous success.

- [#9313](Effect-TS/effect#9313)
[`74a7596`](Effect-TS/effect@74a7596)
Thanks @<!---->effect-bot! - Preserve Reactivity and global layers when
replacing an atom runtime layer through registry initial values, so
injected test layers retain shared invalidation and global services.

- [#9190](https://github.com

…[full
notes](https://github.com/Effect-TS/effect/releases/tag/effect%404.0.6)

</details>

---
*This PR was auto-generated by
[catalog-update-action](https://github.com/brandhaug/catalog-update-action).*

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

This branch is waiting to be deployed

1 waiting deployment
fork — cef5fc9a Waiting Oct 18, 2026 by effect-bot via approval-gate #21077
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants