Skip to content

fix[DeepPartial, DeepReadonly]: preserve branded primitive types - #2074

Merged
dayongkr merged 4 commits into
toss:mainfrom
Tnalxmsk:fix/preserve-branded-primitives
Sep 10, 2026
Merged

dayongkr merged 4 commits into
toss:mainfrom
Tnalxmsk:fix/preserve-branded-primitives

Conversation

@Tnalxmsk

@Tnalxmsk Tnalxmsk commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR follows up on the brand-preservation regression item listed under “Worth doing regardless” in #1986.

DeepPartial and DeepReadonly recursively 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.

type UserId = string & { readonly __brand: 'UserId' };

type User = {
  id: UserId;
};

type PartialUser = DeepPartial<User>;
// Expected: { id?: UserId }

type ReadonlyUser = DeepReadonly<User>;
// Expected: { readonly id: UserId }

The shared Primitive type 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

  • Use the existing Primitive type as a terminal condition in DeepPartial and DeepReadonly.
  • Add type-level regression tests covering both direct and nested branded primitive inputs.
  • Replace the explicit any[] function constraints with never[], preserving parameterized function types without lint warnings.
  • Update the related reference documentation in English, Korean, Japanese, and Simplified Chinese.

Implementation notes

This is a type-only change and does not affect runtime behavior. Reusing the existing Primitive type avoids duplicating the primitive union across both utilities.

The never[] function constraint still matches arbitrary function signatures in the conditional type while avoiding an explicit any.

@Tnalxmsk
Tnalxmsk requested a review from raon0211 as a code owner August 31, 2026 07:09
Copilot AI lite review requested due to automatic review settings August 31, 2026 07:09
@Tnalxmsk
Tnalxmsk requested a review from dayongkr as a code owner August 31, 2026 07:09
@vercel

vercel Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
es-toolkit Ready Ready Preview Sep 10, 2026 8:55am UTC

Request Review

Copilot AI 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.

🟡 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, and Simplify to stop recursion/flattening when T 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.

Comment thread src/types/Simplify.spec.ts Outdated
Comment on lines +5 to +9
it('preserves branded primitive types', () => {
type UserId = string & { readonly __brand: 'UserId' };

expectTypeOf<Simplify<UserId>>().toEqualTypeOf<UserId>();
});
Comment thread src/types/Simplify.ts Outdated
* // 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] } & {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@Tnalxmsk Tnalxmsk changed the title fix[DeepPartial, DeepReadonly, Simplify]: preserve branded primitive types fix[DeepPartial, DeepReadonly]: preserve branded primitive types Sep 10, 2026

@dayongkr dayongkr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for your contribution!!

@dayongkr
dayongkr merged commit 2b60bce into toss:main Sep 10, 2026
12 checks passed
@Tnalxmsk
Tnalxmsk deleted the fix/preserve-branded-primitives branch September 10, 2026 09:52

This branch was successfully deployed

1 active deployment
Preview — 4aceadac Deployed Sep 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants