Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add hasProp, hasPropSatisfying, and isEqual #515

Open
wants to merge 20 commits into
base: master
Choose a base branch
from

Conversation

airjp73
Copy link
Contributor

@airjp73 airjp73 commented Feb 15, 2024

Resolves #461

Summary

Adds three new helpers:

  • isEqual does a shallow, Object.is equality comparison and acts as a type guard
  • hasProp checks if a given object has a property and that it's defined
  • hasPropSatisfying checks if a given object has property, that it's defined, and that the provided predicate/type guard returns true.

Design considerations

isEqual

I originally thought to just do an === comparison, but I thought Object.is made more sense since it's more robust to values like NaN.

hasProp

My original plan was to only check for the presence of a property and allow checks like hasProp({ a: undefined }, 'a') to return true.

However, Typescript removes undefined from the possible values when removing the optionality of the prop (related github issue). The linked thread has some workarounds, but I wasn't able to get any of them to work in a general enough way for this helper.

This means that the narrowed type resulting from the type guard couldn't be accurate if we returned true for present keys with undefined as the value.

I think returning false when the value is undefined is a reasonable trade-off to make to keep the types completely accurate.

hasPropSatisifying

Same considerations as hasProp, but the type accuracy issue also extends to the type of the argument passed to the predicate.

Potential future improvements

Lodash and Ramda both have the ability to check for properties at a given path.

  • Lodash's has supports it directly
  • Ramda has a separate hasPath helper

As a future improvement, we could conceivably add a couple hasPropAtPath and hasPropAtPathSatisfying helpers.


Make sure that you:

  • Typedoc added for new methods and updated for changed
  • Tests added for new methods and updated for changed
  • New methods added to src/index.ts
  • New methods added to mapping.md

We use semantic PR titles to automate the release process!

https://conventionalcommits.org

PRs should be titled following using the format: < TYPE >(< scope >)?: description

Available Types:

  • feat: new functions, and changes to a function's type that would impact users.
  • fix: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
  • perf: changes to function implementations that improve a functions runtime performance.
  • refactor: changes to function implementations that are neither fix nor perf
  • test: tests-only changes (transparent to users of the function).
  • docs: changes to the documentation of a function or the documentation site.
  • build, ci, style, chore, and revert: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

Copy link

codesandbox-ci bot commented Feb 15, 2024

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit e4023c9:

Sandbox Source
remeda-example-vanilla Configuration

Copy link

codecov bot commented Feb 15, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 98.54%. Comparing base (96a4e82) to head (e4023c9).
Report is 50 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #515      +/-   ##
==========================================
+ Coverage   98.41%   98.54%   +0.12%     
==========================================
  Files         127      138      +11     
  Lines        7954     9271    +1317     
  Branches      724      782      +58     
==========================================
+ Hits         7828     9136    +1308     
- Misses        123      132       +9     
  Partials        3        3              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Collaborator

@eranhirsch eranhirsch left a comment

Choose a reason for hiding this comment

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

  1. I think it's unlikely that people would consider an array as having a prop at an index. We already offer hasAtLeast to narrow arrays so that accessing a specific prop would result in a non-undefined value. Handling arrays in these functions complicates their type considerably and might introduce edge-cases and bugs that would be hard to find and fix. Let's keep hasProp and hasPropSatisfying for proper objects just as pick, omit, and prop are.

  2. Handling unions specifically is also beyond what the utility functions should handle. We don't handle these issues with prop, pick, or omit either. If this is to handle discriminated unions, it's usually recommended to have a pivot prop that would allow typescript to align the types. I personally encountered issues in the past with trying to rely on prop existence in order to discriminate a union. Let's keep this simple for now, and we can always go back to this and expand it if we see that the simple solution isn't working for people.

mapping.md Outdated Show resolved Hide resolved
mapping.md Outdated Show resolved Hide resolved
src/_objectProps.ts Show resolved Hide resolved

export type AllUnionKeys<Union> = Union extends Union ? keyof Union : never;

type MakeKeyRequired<Obj, Prop extends keyof Obj> = Obj & {
Copy link
Collaborator

Choose a reason for hiding this comment

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

To prevent order from being an issue here you'd need to remove the props from Obj first.

Suggested change
type MakeKeyRequired<Obj, Prop extends keyof Obj> = Obj & {
type MakeKeyRequired<Obj, Prop extends keyof Obj> = Omit<Obj, Prop> & {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was my original approach but ran into issues with the guard types. Typescript was thinking that this type was no longer assignable to the original.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Spent a little time trying to add this in and ran into the same issue. I'd need to do some more research to figure out exactly why, but TS gives this error in the hasProp implementation file:

A type predicate's type must be assignable to its parameter's type.

src/isEqual.ts Outdated Show resolved Hide resolved
src/isEqual.ts Outdated Show resolved Hide resolved
src/isEqual.ts Outdated Show resolved Hide resolved
src/isEqual.test.ts Outdated Show resolved Hide resolved
src/isEqual.test.ts Outdated Show resolved Hide resolved
@airjp73
Copy link
Contributor Author

airjp73 commented Feb 15, 2024

  1. I think it's unlikely that people would consider an array as having a prop at an index. We already offer hasAtLeast to narrow arrays so that accessing a specific prop would result in a non-undefined value. Handling arrays in these functions complicates their type considerably and might introduce edge-cases and bugs that would be hard to find and fix. Let's keep hasProp and hasPropSatisfying for proper objects just as pick, omit, and prop are.

Good callout. I didn't realize that was there! I only added the type handling because it the implementation technically supports it without extra effort.

  1. Handling unions specifically is also beyond what the utility functions should handle. We don't handle these issues with prop, pick, or omit either. If this is to handle discriminated unions, it's usually recommended to have a pivot prop that would allow typescript to align the types. I personally encountered issues in the past with trying to rely on prop existence in order to discriminate a union. Let's keep this simple for now, and we can always go back to this and expand it if we see that the simple solution isn't working for people.

I'm not sure I understand the perspective that this is beyond the scope of these helpers. I usually agree with implementing less complete feature sets and filling it out over time, but since we already have a more complete feature set it doesn't make sense to me to pare it down now.

I agree that prop, pick, and omit shouldn't have this handling. For the same reason that writing obj.someKey will error if a key might not exist, prop should too.

But since these helpers are type guards, the whole point is to take a wider type and narrow it.

@eranhirsch
Copy link
Collaborator

Those are good points about it being a type-guard requiring extra care about the types. I think the best thing would be to ensure that there are tests covering these concerns so that we can't accidentally break the code for these cases without even knowing.

None of the tests break at the moment when I remove the Narrow utility from the types in isEqual, for example.

@airjp73
Copy link
Contributor Author

airjp73 commented Feb 18, 2024

In the issue, there was some discussion about the behavior of the isEqual function. I kept moving forward with the referential equality implementation and the isEqual name for now, since it seemed like shallow and deep equals would have more specific names. Happy to change it you want though.

src/_objectProps.ts Outdated Show resolved Hide resolved
src/_objectProps.ts Outdated Show resolved Hide resolved
src/_objectProps.ts Outdated Show resolved Hide resolved
Copy link
Collaborator

@eranhirsch eranhirsch left a comment

Choose a reason for hiding this comment

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

undefined is not special when it comes to prop values, it should be treated as a valid value and that should be represented both in the types and the runtime behaviour of hasProp.

See my comment inline in the test file.

src/_objectProps.ts Show resolved Hide resolved
export type AllUnionKeys<Union> = Union extends Union ? keyof Union : never;

// Used to ensure the type asserted by a type guard is recognized as a subtype of the original type.
export type EnsureExtends<T, U> = U extends T ? U : Simplify<T & U>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need to simplify here? Obviously, all types would benefit from being simplified, but I'm not sure when it's actually useful. Although this doesn't affect runtime performance, Typescript itself can become sluggish when too much recursive and iterative inference is expected from it.

Comment on lines +10 to +26
type MakeKeyRequired<Obj, Prop extends keyof Obj> = Omit<Obj, Prop> & {
// We need to both remove the optional modifier and exclude undefined.
// This is because the `-?` modifier will only exclude undefined from the type if the property is optional,
// which makes the behavior of the type inconsistent.
// More info about this can be found here: https://github.com/microsoft/TypeScript/issues/31025
// So excluding `undefined` explicitly also does this for required properties that include undefined.
[key in Prop]-?: Exclude<Obj[key], undefined>;
};

type UnionMembersWithProp<Obj, Prop extends keyof Obj> = Extract<
Obj,
{ [key in Prop]?: unknown }
>;

export type WithRequiredProp<Obj, Prop extends AllUnionKeys<Obj>> = Simplify<
MakeKeyRequired<UnionMembersWithProp<Obj, Prop>, Prop>
>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's pull this out into the type-fest folder and copy:

https://github.com/sindresorhus/type-fest/blob/main/source/set-required.d.ts

Comment on lines +24 to +27
it("should return false when a prop is present on the object but is undefined", () => {
const obj = { b: undefined };
expect(hasProp(obj, "b")).toBe(false);
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is incorrect and relies on a more permissive type definition. When the typescript project uses exactOptionalPropertyTypes an undefined value is strictly different than an optional prop not being present. This is also how it works during runtime.

console.log('a' in { a: undefined }); // "true"
console.log('a' in {}); // "false"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This behavior was an intentional trade-off. I can play around a bit more since it's been awhile, but I remember something at the type level couldn't reliably distinguish between the two.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Check out type-fest types for some of your utility types under _objectProps, they most likely can solve most of those issues.

hasProp should work as closely as possible to "prop" in <obj> otherwise some users would either misunderstand it's semantics and run into problems in runtime, or need to build their own function for the behaviour they need.

hasPropSatisfying(obj, prop, isDefined.strict) can provide the functionality you define for hashProp here, but the opposite is not possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll check it out and see what I can do. This is the issue I reference elsewhere in the PR that lead me to this decision. microsoft/TypeScript#31025

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.

[Feature Request]: Object property predicates like hasProp (includes proof of concept)
3 participants