-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Improve TypeScript type-safety of cache.modify
#10895
Improve TypeScript type-safety of cache.modify
#10895
Conversation
@Gelio: Thank you for submitting a pull request! Before we can merge it, you'll need to sign the Apollo Contributor License Agreement here: https://contribute.apollographql.com/ |
👷 Deploy request for apollo-client-docs pending review.Visit the deploys page to approve it
|
🦋 Changeset detectedLatest commit: fc50711 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
088691e
to
bbc2cea
Compare
Hey @Gelio 👋 Thanks so much for this PR! I'd like to talk about this a bit more with the team and pull this down to assess the backwards compatibility. We should be able to take a look at it next week. Thanks so much for helping make this library better for TypeScript users! |
That's great! I'm looking forward to your feedback As far as backwards compatibility goes, both commits should be transparent to end users who haven't added any type annotations themselves in the past. The default types are still |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heyo, thanks a ton for the PR - it definitely makes things a lot better already. I have added a few comments. We might consider going a step further than this for easier discoverability.
src/cache/core/types/common.ts
Outdated
? StoreVal | Reference | ||
: StoreVal; | ||
|
||
export type Modifiers<T extends Record<string, unknown>> = Partial<{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should have a default value to be backwards compatible.
export type Modifiers<T extends Record<string, unknown>> = Partial<{ | |
export type Modifiers<T extends Record<string, unknown> = Record<string, unknown>> = Partial<{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea 👍 I did that
src/cache/core/types/common.ts
Outdated
declare const _deleteModifier: unique symbol; | ||
export type DeleteModifier = typeof _deleteModifier | ||
declare const _invalidateModifier: unique symbol; | ||
export type InvalidateModifier = typeof _invalidateModifier |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
declare const _deleteModifier: unique symbol; | |
export type DeleteModifier = typeof _deleteModifier | |
declare const _invalidateModifier: unique symbol; | |
export type InvalidateModifier = typeof _invalidateModifier | |
declare const _deleteModifier: unique symbol; | |
export interface DeleteModifier { [_deleteModifier]: true } | |
declare const _invalidateModifier: unique symbol; | |
export interface InvalidateModifier { [_invalidateModifier]: true} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that earlier but didn't come up with a way of fixing it. Thanks for a ready-made snippet! I used that in the code
src/cache/core/types/Cache.ts
Outdated
@@ -59,7 +59,7 @@ export namespace Cache { | |||
|
|||
export interface ModifyOptions { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you considered adding a generic to ModifyOptions
and adding an overload using that cache.modify
?
While I love the extra safety we are already getting here through satisfies Modifiers<Book>
, it is not a very discoverable solution, so it might be an idea to explore that additional step.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea. I added a generic parameter to modify
with any
as the default value. It seems to work good enough and is backwards compatible, as far as I can tell.
My previous attempt without the `any` default type
So far, I added the generic like so:
export interface ModifyOptions<T> {
id?: string;
fields: T extends Record<string, unknown>
? Modifiers<T> | Modifier<T>
: Modifier<T>;
optimistic?: boolean;
broadcast?: boolean;
}
I added the extends Record<string, unknown>
conditional type because Modifiers
expect its generic parameter to extend Record<string, unknown>
.
I believe that this conditional type makes type inference fail to infer the type of T
based on the fields
object:
declare function modify<T>(options: ModifyOptions<T>): void;
modify({
fields: {
bar: (value, options) => {
return value;
}
}
})
T
is inferred as unknown
, whereas it could be inferred to { bar: unknown }
.
Thus, looks like a default type of any
is needed to make this change backwards-compatible.
Thanks for your review! I addressed the comments. I haven't rebased on |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, thank you!
Just one minor nitpick - maybe we can avoid that any
there :)
src/cache/inmemory/inMemoryCache.ts
Outdated
@@ -204,7 +204,7 @@ export class InMemoryCache extends ApolloCache<NormalizedCacheObject> { | |||
} | |||
} | |||
|
|||
public modify(options: Cache.ModifyOptions): boolean { | |||
public modify<Entity = any>(options: Cache.ModifyOptions<Entity>): boolean { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we could go for Record<string, any>
as a default here - or am I missing any edge case where modify
could be called on something that is not an object?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, good point 👍 I made that change in a separate commit
09a0528
to
3681939
Compare
I rebased the branch on top of the latest |
Problem: The `DELETE` and `INVALIDATE` modifiers type is `any`. Returning them from the `Modifier<T>` function triggers the `@typescript-eslint/no-unsafe-return` ESLint rule [0], since the modifier is then returning a value with type `any`. Solution: Set the types of these modifiers to two different `unique symbol`s. These serve as unique opaque types. The consuming code should not peek into them. These 2 types are permitted in the return type of the `Modifier<T>` function, since they are not `any` anymore, and thus, are not assignable to `T`. This change only affects TypeScript types. It has no runtime impact. [0]: https://typescript-eslint.io/rules/no-unsafe-return/
Problem: The `fields` property in `cache.modify` did not offer ways to infer the field names or values based on the field type. It accepted any field names and the field values were `any`. Solution: Add a generic type parameter for the object type to the `Modifiers` type. The code can now use the `satisfies Modifiers<...>` operator to inform TypeScript about the possible field names and values. Field values include `Reference`s for objects and arrays. The consuming code is then responsible for checking (or asserting) that the field value is or is not a reference.
Makes the change backwards-compatible.
Interfaces retain their names in error messages and hover dialogs. Type aliases did not.
A generic parameter is easier to discover and set than `fields: { ... } satisfies Modifiers<...>`
Use a more appropriate real-life default type for `cache.modify`.
Just looking at this, I'm gonna add some more changes and rebase this on 3.8 One thing that I noticed:
|
Right, I always had trouble with these 2 variants of |
3681939
to
6973cd4
Compare
@@ -2817,7 +2817,7 @@ describe("InMemoryCache#modify", () => { | |||
|
|||
cache.modify({ | |||
fields: { | |||
comments(comments: Reference[], { readField }) { | |||
comments(comments: readonly Reference[], { readField }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change might introduce a bit of churn for our users (but for good reason) - instead of allowing Reference[] | ObjectType[]
here, we pass these arrays in as ReadonlyArray
.
We should talk about this at the meeting tomorrow.
I've given this a rebase on 3.8 and a force-push. @Gelio I have also introduced some changes - could you please give my review a review? General changes:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for taking care of this PR 👍 I especially like the types tests you added.
I added some comments. I believe only the one in src/cache/inmemory/types.ts
is worth taking care of before the merge. Others are non-blocking and suggestions for improvements.
Let me know if you want me to address them. I thought I would just bring them up so you can have your say before we modify the code more.
@@ -2,7 +2,7 @@ import gql from 'graphql-tag'; | |||
import { ApolloCache } from '../cache'; | |||
import { Cache, DataProxy } from '../..'; | |||
import { Reference } from '../../../utilities/graphql/storeUtils'; | |||
|
|||
import { expectTypeOf } from 'expect-type' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am afraid tests are excluded from typechecking. I intentionally created a typechecking error and npm run build
passed
Am I missing something?
If not, then perhaps we should start also typechecking tests to catch errors there. It would probably be safest to do in a separate PR. I'm just bringing it up here as I noticed a possible improvement (or a lack of understanding on my part)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They are from building, but they are checked when running the tests - also in CI.
See this CI run: https://app.circleci.com/pipelines/github/apollographql/apollo-client/20241/workflows/ece99794-faac-4ed0-b938-f8fe5e2b697c/jobs/102866
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. Thanks for the info. I missed that typechecking happens during tests.
n cases like this one, I usually see 2 TS projects: one for the source code, one for the tests. The build typechecking (that happens in npm run build
) can only compile the code project, while there could be a separate npm script that typechecks both projects. This way the typechecking would be easier to discover. Just some food for thought from an external contributor 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Gelio I'm late to the party, but figured I'd offer you (and others that stumble upon this comment) an explanation as to why you're seeing this.
We deliberately disabled strict type checking in development (#10268) because we wanted the ability to comment out code when debugging issues against failing tests while allowing our tests to run with broken types. Sometimes the easiest way to figure out why something is failing is to comment out specific parts of code to figure out what line of code is causing the issue. Debugging like this in some cases cascaded all over the place (I remember one of these to spot check a specific line of code and I ended up having to comment 50-60 lines of code just to get the test to run).
Instead, we allow TypeScript errors, but have it just show a warning at the very top of the tests. This allows us to see there are type issues, but our tests will still run. As @phryneas said, we have strict type checking when running in CI, so it will be caught in PRs and such.
Hope this helps!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jerelmiller Thanks for a thorough explanation!
I understand the problem you were trying to solve. I would also be frustrated at the tests not attempting to run because there are some unused symbols in the code
A solution I see more commonly used in the wild is fully separating the typechecking from testing. I don't see much of the reason for jest
(or ts-jest
) to do both. As a fellow engineer, I would much rather have a dedicated npm script for typechecking, and a separate one for just running the tests (without checking the types). That would be less of a surprise than the current state of affairs 🙂
Maybe you would also get faster feedback this way, since jest
would only strip the types and not have to compile the project first. This opens up possibilities of using a different jest transpiler, like @swc/jest, which could be faster at transpiling TS than tsc
I thought I would share this perspective of a first-time contributor.
Please go ahead and continue fixing things here - thank you for working with us on this :) |
Use an already installed library instead of introducing a new utility function.
Promote the use of a generic parameter of `cache.modify` over using `satisfies` which requires importing the `Modifers` type.
I'm happy to contribute :) I've just applied to suggestions and pushed 3 commits to the branch. The code looks good to me now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me now! Before we get this merged, I'll discuss the change to ReadonlyArray
internally to get a team decision on that part.
@phryneas Thanks for merging the PR 🎉 |
@Gelio thank you for putting the work in and opening the PR in the first place! 😊 |
This PR contains 2 changes that aim to improve the type safety of
fields
incache.modify
.DELETE
andINVALIDATE
modifiers, so they are notany
. This helps make the@typescript-eslint/no-unsafe-return
rule happy.Modifiers
type to get type inference forfields
names and values.I described the changes in the commits. Look at their descriptions for more information.
Both changes only affect TypeScript types. There are no runtime changes.
I understand the 2nd commit is riskier than the first commit. I can split it into 2 PRs if you want to take longer with the review.
Checklist: