Skip to content

fix[omitBy, pickBy]: keep index signature types - #2019

Open
Hprogram wants to merge 1 commit into
toss:mainfrom
Hprogram:fix/issue-1987-omitby-pickby-record-types
Open

fix[omitBy, pickBy]: keep index signature types#2019
Hprogram wants to merge 1 commit into
toss:mainfrom
Hprogram:fix/issue-1987-omitby-pickby-record-types

Conversation

@Hprogram

Copy link
Copy Markdown
Contributor

Summary

omitBy and pickBy always return Partial<T>. For objects typed with an index signature that is wrong in a way that breaks real code — the result can no longer be assigned back to the type it came from:

type ItemLookup = Record<number, Item>;
declare function processItems(items: ItemLookup): void;

const items: ItemLookup = {};
processItems(omitBy(items, i => i.name.startsWith('A')));
//           ^ Argument of type 'Partial<ItemLookup>' is not assignable
//             to parameter of type 'ItemLookup'

Record<string, T> and Record<number, T> already allow any key to be absent, so wrapping them in Partial adds nothing and only breaks assignability. Objects with known keys are a different case: omitBy really can drop any of them, so Partial<T> stays correct there. es-toolkit/compat already draws this line through overloads.

fixes #1987

Changes

  • omitBy and pickBy now return T when T has a string or number index signature, and Partial<T> otherwise.
  • The implementations are unchanged — this is a type-only fix.
  • Type tests for both index signature kinds, the reproduction from the issue, and a regression test asserting that objects with known keys still produce Partial<T>.
  • Documentation updated in all four languages.

I used a conditional return type rather than the overloads suggested in the issue. With overloads, an object literal type such as { a: number; b: string } also matches the Record<string, T> signature through its implicit index signature, which would change the return type for the common case.

One case this does not cover, and it is a trade-off rather than an omission: for a type that mixes an index signature with declared properties, such as interface Mixed { [key: string]: unknown; id: number }, the result keeps id as required even though omitBy may have dropped it. Today that same type returns Partial<Mixed>, which is sound but cannot be assigned back. The index signature branch wins here, and the overloads suggested in the issue behave the same way. If you would rather keep Partial<T> for these, I can restrict the branch to types whose keys are only an index signature.

`omitBy` and `pickBy` always returned `Partial<T>`, so results from
objects typed with an index signature could not be assigned back to the
original type:

    type ItemLookup = Record<number, Item>;
    declare function processItems(items: ItemLookup): void;
    processItems(omitBy(items, i => i.name.startsWith('A')));
    // Argument of type 'Partial<ItemLookup>' is not assignable to
    // parameter of type 'ItemLookup'

`Record<string, T>` and `Record<number, T>` already allow any key to be
missing, so wrapping them in `Partial` adds nothing but breaks
assignability. Return `T` unchanged when `T` has a string or number
index signature, and keep `Partial<T>` for objects with known keys,
where any property may genuinely have been dropped.

This is a type-only change; the implementations are untouched.

Closes toss#1987
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
es-toolkit Ready Ready Preview Aug 14, 2026 7:03am

Request Review

@dayongkr

Copy link
Copy Markdown
Collaborator

Small thing on the framing: Record<string, V> does have undefined at runtime, we just don't see it because noUncheckedIndexedAccess is off by default. Direction is right regardless.

You flagged the mixed case as a trade-off. I think it's avoidable rather than something to pick. Branching on the shape leaves three:

Record<string, number> & { required: number } // required stays required, omitBy can still drop it
{ [k: symbol]: number }                       // still Partial, same bug as the issue
union input                                   // falls back to Partial<T>

Splitting T instead of branching on it gets all three:

type PickIndexSignature<T> = { [K in keyof T as {} extends Record<K, unknown> ? K : never]: T[K] };
type OmitIndexSignature<T> = { [K in keyof T as {} extends Record<K, unknown> ? never : K]: T[K] };

export type OmitByResult<T> = Simplify<PickIndexSignature<T> & Partial<OmitIndexSignature<T>>>;

Simplify is already in src/types. {} extends Record<K, unknown> is the test for an index signature key, since an empty object satisfies Record<string, unknown> but not Record<'a', unknown>.

No cases in it, just the rule. A key that was already allowed to be missing stays as it is, a declared key gets marked optional.

Tried it locally since it's type-only: full suite and typecheck pass. The one assertion that changes is the mixed-key one, which becomes expectTypeOf(result.required).toEqualTypeOf<number | undefined>().

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.

omitBy / pickBy types

2 participants