Skip to content

Fix: Behaviour of OptionalKeys when instantiated with primitives and arrays#414

Merged
Beraliv merged 6 commits into
ts-essentials:masterfrom
som-sm:fix/optional-keys-with-primitives
Oct 28, 2024
Merged

Fix: Behaviour of OptionalKeys when instantiated with primitives and arrays#414
Beraliv merged 6 commits into
ts-essentials:masterfrom
som-sm:fix/optional-keys-with-primitives

Conversation

@som-sm

@som-sm som-sm commented Oct 21, 2024

Copy link
Copy Markdown
Contributor

PR Checklist

  • Addresses an existing open issue: related to #000
  • Steps in Contributing were taken

Overview

Currently, the OptionalKeys type doesn’t handle primitives and arrays as expected.

Handling Primitives Correctly

Since OptionalKeys is a homomorphic mapped type, so when it’s instantiated with a primitive Type, it simply returns back Type. As a result, instantiating OptionalKeys with string yields string[keyof string], which isn’t the intended result.

The fix here is straightforward, we simply bypass primitives by changing the conditional from Type extends unknown to Type extends object. So, when OptionalKeys is instantiated with primitives, the condition evaluates to false, returning never as expected.

Handling Arrays Correctly

Ideally, OptionalKeys<[1, 2?]> should return "1", but instead, it returns a bunch of unintended things.

Here's the existing code for reference:

export type OptionalKeys<Type> = Type extends unknown
  ? {
      [Key in keyof Type]-?: undefined extends { [Key2 in keyof Type]: Key2 }[Key] ? Key : never;
    }[keyof Type]
  : never;

Let's break down what happens when OptionalKeys is instantiated with [1, 2?].

  • Since the inner mapped type ({ [Key2 in keyof Type]: Key2 }) is homomorphic, it correctly preserves the tuple structure and evaluates to [Key2, Key2?] (Note: Key2 is insignificant here; could have simply been never as well). Therefore, the conditional undefined extends ... evaluates to true only when Key is "1".
  • Consequently, because the outer mapped type is also homomorphic, it correctly evaluates to [never, "1"].
  • However, the problem arises when we try to do [never, "1"][keyof T] as this yields a bunch of unintended keys. Instead, if we index it with the number type, we get the desired result of "1".

The fix involves using number as the index for arrays instead of keyof Type.

Additional Improvements & Tests:

  1. Added a couple of more test cases to cover arrays and functions.
  2. Replaced Key2 with never in the inner mapped type for better readability. Using a specific type like Key2 can imply that its value is being utilised, which isn’t the case here.

@som-sm

som-sm commented Oct 21, 2024

Copy link
Copy Markdown
Contributor Author

Hey @Beraliv, this is my first time contributing to this repository, please let me know if it's required to open an issue first. Thanks!

@Beraliv

Beraliv commented Oct 21, 2024

Copy link
Copy Markdown
Collaborator

Hey @som-sm! Thank you for taking your time and efforts to contribute to ts-essentials! I won't be able to have a look at your changes today, so I will come back to you tomorrow! Thank you for understanding in advance

@Beraliv

Beraliv commented Oct 23, 2024

Copy link
Copy Markdown
Collaborator

Quickly looking at the code, your changes look reasonable to me. I let the CI run on all TS versions to see if there are any regressions. I'll double-check the changes more thoroughly today to see if I missed anything. I'll ping you if needed

@som-sm

som-sm commented Oct 23, 2024

Copy link
Copy Markdown
Contributor Author

Looks like something probably got fixed in v4.9.

From a quick test, the issue seems to be here:

type Test<Type> = {
  [Key in keyof Type]-?: [{ [Key2 in keyof Type]: never }[Key]];
};

type T = Test<[1, 2?]>;
//   ^?

Playground Link: https://tsplay.dev/w8Dd0w

For v4.8 and below

T is of type [[never], [never]]

Which is why the conditional undefined extends ... in OptionalKeys is not becoming true for the second item.
Although normally indexing an optional item, does give back | undefined.

type T1 = [1, 2?][1];
//   ^? type T1 = 2 | undefined

Whereas for v4.9 and above

T is of type [[never], [undefined]]

@som-sm

som-sm commented Oct 23, 2024

Copy link
Copy Markdown
Contributor Author

I've refactored the way we check for optional properties, this seems to be working fine for v4.8.

export type OptionalKeys<Type> = Type extends object
  ? {
-     [Key in keyof Type]-?: undefined extends { [Key2 in keyof Type]: Key2 }[Key] ? Key : never;
+     [Key in keyof Type]-?: Type extends Required<Pick<Type, Key>> ? never : Key;
    }[keyof Type & (Type extends ReadonlyArray<any> ? number : keyof Type)]
  : never;

Comment thread test/index.ts
Comment thread test/index.ts
Comment thread .changeset/ninety-forks-poke.md Outdated
@Beraliv

Beraliv commented Oct 26, 2024

Copy link
Copy Markdown
Collaborator

@som-sm thank you for your patience! I've reviewed your changes in detail and left 3 comments. The rest LGTM! Please address my comments and I'm happy to merge your changes

@som-sm

som-sm commented Oct 27, 2024

Copy link
Copy Markdown
Contributor Author

@Beraliv Thanks for reviewing the PR. I've replied back to all the comments, please have a look.

Also, there's scope for similar improvements in ReadonlyKeys and WritableKeys types, I'll raise those PRs soon.

@som-sm

som-sm commented Oct 27, 2024

Copy link
Copy Markdown
Contributor Author

@Beraliv, while fixing the ReadonlyKeys type, I realised there's a much simpler way of implementing the OptionalKeys type that doesn't require any special handling for arrays. I've updated the PR, please refer to this commit.

@Beraliv Beraliv 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.

Nice usage of key remapping, overall LGTM, but a follow-up on tests

Comment thread lib/optional-keys/index.ts
@Beraliv

Beraliv commented Oct 28, 2024

Copy link
Copy Markdown
Collaborator

@som-sm I'm happy to merge your PR now, let me know what you think about my last comment with any

@som-sm

som-sm commented Oct 28, 2024

Copy link
Copy Markdown
Contributor Author

@Beraliv I think it's just fine to keep any as the value there.

@Beraliv

Beraliv commented Oct 28, 2024

Copy link
Copy Markdown
Collaborator

@som-sm no objections from me! Great work, merging your PR now!

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.

2 participants