Repository navigation
fix[DeepPartial, DeepReadonly]: preserve branded primitive types - #2074
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
🟡 Changes recommended
The new Simplify regression test doesn’t cover the nested branded-primitive case described as a goal of the PR.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a type-level regression in es-toolkit/types where branded primitive types (e.g. string & { __brand: ... }) were being treated as objects by DeepPartial, DeepReadonly, and Simplify, causing the brand to be lost after transformation. It does this by using the shared Primitive type as a terminal condition so primitives (including branded primitives) pass through unchanged.
Changes:
- Update
DeepPartial,DeepReadonly, andSimplifyto stop recursion/flattening whenT extends Primitive. - Add regression tests for branded primitives (direct inputs; plus nested for
DeepPartial/DeepReadonly). - Update reference docs in English/Korean/Japanese/Simplified Chinese to document primitive pass-through behavior.
File summaries
| File | Description |
|---|---|
| src/types/Simplify.ts | Treat Primitive as a terminal case so branded primitives aren’t flattened via the object/mapped-type branch. |
| src/types/Simplify.spec.ts | Adds a regression test for branded primitives (currently only direct, not nested). |
| src/types/DeepReadonly.ts | Adds Primitive to the pass-through branch to avoid recursively mapping branded primitives as objects. |
| src/types/DeepReadonly.spec.ts | Adds regression tests for branded primitives (direct + nested in object properties). |
| src/types/DeepPartial.ts | Adds Primitive to the pass-through branch to avoid recursively mapping branded primitives as objects. |
| src/types/DeepPartial.spec.ts | Adds regression tests for branded primitives (direct + nested in object properties). |
| docs/types/reference/objects/Simplify.md | Documents that primitives (including branded primitives) pass through unchanged. |
| docs/types/reference/objects/DeepReadonly.md | Documents that primitives (including branded primitives) pass through unchanged. |
| docs/types/reference/objects/DeepPartial.md | Documents that primitives (including branded primitives) pass through unchanged. |
| docs/ko/types/reference/objects/Simplify.md | Same documentation update (Korean). |
| docs/ko/types/reference/objects/DeepReadonly.md | Same documentation update (Korean). |
| docs/ko/types/reference/objects/DeepPartial.md | Same documentation update (Korean). |
| docs/ja/types/reference/objects/Simplify.md | Same documentation update (Japanese). |
| docs/ja/types/reference/objects/DeepReadonly.md | Same documentation update (Japanese). |
| docs/ja/types/reference/objects/DeepPartial.md | Same documentation update (Japanese). |
| docs/zh_hans/types/reference/objects/Simplify.md | Same documentation update (Simplified Chinese). |
| docs/zh_hans/types/reference/objects/DeepReadonly.md | Same documentation update (Simplified Chinese). |
| docs/zh_hans/types/reference/objects/DeepPartial.md | Same documentation update (Simplified Chinese). |
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| it('preserves branded primitive types', () => { | ||
| type UserId = string & { readonly __brand: 'UserId' }; | ||
|
|
||
| expectTypeOf<Simplify<UserId>>().toEqualTypeOf<UserId>(); | ||
| }); |
| * // hover => { name: string; age: number } (instead of A & B) | ||
| */ | ||
| export type Simplify<T> = { [K in keyof T]: T[K] } & {}; | ||
| export type Simplify<T> = T extends Primitive ? T : { [K in keyof T]: T[K] } & {}; |
There was a problem hiding this comment.
DeepPartial and DeepReadonly look good. They recurse into every value, so a branded field breaks even when the input is a plain object.
Could you drop the Simplify change? It expands one level and leaves field types alone, so a primitive only reaches it when passed at the top level, which is outside what the docs describe.
There was a problem hiding this comment.
Thanks for clarifying the intended scope. That makes sense — Simplify only expands the top-level shape and already preserves branded types in object properties.
I've removed the Simplify implementation, tests, and documentation changes, and narrowed the PR to DeepPartial and DeepReadonly. I've also updated the PR title and description accordingly.
dayongkr
left a comment
There was a problem hiding this comment.
Thanks for your contribution!!
Summary
This PR follows up on the brand-preservation regression item listed under “Worth doing regardless” in #1986.
DeepPartialandDeepReadonlyrecursively transform object property values. When a plain object contains a branded primitive field, that field is processed through the object branch and is no longer equivalent to the original branded type.The shared
Primitivetype introduced in [#2044](#2044) provides a common terminal condition for these recursive utilities. Primitive types, including branded primitive types, can therefore pass through without being recursively mapped as objects.Changes
Primitivetype as a terminal condition inDeepPartialandDeepReadonly.any[]function constraints withnever[], preserving parameterized function types without lint warnings.Implementation notes
This is a type-only change and does not affect runtime behavior. Reusing the existing
Primitivetype avoids duplicating the primitive union across both utilities.The
never[]function constraint still matches arbitrary function signatures in the conditional type while avoiding an explicitany.