Skip to content

Commit

Permalink
feat(cache): type inference for cache Modifiers type
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Gelio committed May 19, 2023
1 parent 587e855 commit bbc2cea
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 36 deletions.
27 changes: 27 additions & 0 deletions .changeset/afraid-zebras-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@apollo/client": minor
---

Add generic type parameter for the cache `Modifiers` type. Improves TypeScript
type inference for that type's fields and values of those fields.

Example:

```ts
cache.modify({
id: cache.identify(someBook),
fields: {
title: (title) => {
// title has type `string`.
// It used to be `any`.
},
author: (author) => {
// author has type `Reference | Book["author"]`.
// It used to be `any`.
},
} satisfies Modifiers<Book>,
});
```

To take advantage of the type inference, use [the `satisfies Modifiers<...>`
operator](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html).
2 changes: 1 addition & 1 deletion src/cache/core/types/Cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export namespace Cache {

export interface ModifyOptions {
id?: string;
fields: Modifiers | Modifier<any>;
fields: Modifiers<Record<string, any>> | Modifier<any>;
optimistic?: boolean;
broadcast?: boolean;
}
Expand Down
17 changes: 14 additions & 3 deletions src/cache/core/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ export type Modifier<T> = (
details: ModifierDetails
) => T | DeleteModifier | InvalidateModifier;

export type Modifiers = {
[fieldName: string]: Modifier<any>;
};
type StoreObjectValueMaybeReference<StoreVal> = StoreVal extends Record<
string,
unknown
>[]
? StoreVal | Reference[]
: StoreVal extends Record<string, unknown>
? StoreVal | Reference
: StoreVal;

export type Modifiers<T extends Record<string, unknown>> = Partial<{
[FieldName in keyof T]: Modifier<
StoreObjectValueMaybeReference<Exclude<T[FieldName], undefined>>
>;
}>;
110 changes: 81 additions & 29 deletions src/cache/inmemory/__tests__/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import gql, { disableFragmentWarnings } from 'graphql-tag';

import { cloneDeep } from '../../../utilities/common/cloneDeep';
import { makeReference, Reference, makeVar, TypedDocumentNode, isReference, DocumentNode } from '../../../core';
import { Cache } from '../../../cache';
import { Cache, Modifiers } from '../../../cache';
import { InMemoryCache } from '../inMemoryCache';
import { InMemoryCacheConfig } from '../types';

Expand Down Expand Up @@ -3918,18 +3918,43 @@ describe('TypedDocumentNode<Data, Variables>', () => {
}
`;

it('should determine Data and Variables types of {write,read}{Query,Fragment}', () => {
const cache = new InMemoryCache({
// We need to define these objects separately from calling writeQuery,
// because passing them directly to writeQuery will trigger excess property
// warnings due to the extra __typename and isbn fields. Internally, we
// almost never pass object literals to writeQuery or writeFragment, so
// excess property checks should not be a problem in practice.
const jcmAuthor = {
__typename: "Author",
name: "John C. Mitchell",
};

const ffplBook = {
__typename: "Book",
isbn: "0262133210",
title: "Foundations for Programming Languages",
author: jcmAuthor,
};

const ffplVariables = {
isbn: "0262133210",
};

function getBookCache() {
return new InMemoryCache({
typePolicies: {
Query: {
fields: {
book(existing, { args, toReference }) {
return existing ?? (args && toReference({
__typename: "Book",
isbn: args.isbn,
}));
}
}
return (
existing ??
(args &&
toReference({
__typename: "Book",
isbn: args.isbn,
}))
);
},
},
},

Book: {
Expand All @@ -3941,27 +3966,10 @@ describe('TypedDocumentNode<Data, Variables>', () => {
},
},
});
}

// We need to define these objects separately from calling writeQuery,
// because passing them directly to writeQuery will trigger excess property
// warnings due to the extra __typename and isbn fields. Internally, we
// almost never pass object literals to writeQuery or writeFragment, so
// excess property checks should not be a problem in practice.
const jcmAuthor = {
__typename: "Author",
name: "John C. Mitchell",
};

const ffplBook = {
__typename: "Book",
isbn: "0262133210",
title: "Foundations for Programming Languages",
author: jcmAuthor,
};

const ffplVariables = {
isbn: "0262133210",
};
it("should determine Data and Variables types of {write,read}{Query,Fragment}", () => {
const cache = getBookCache();

cache.writeQuery({
query,
Expand Down Expand Up @@ -4041,4 +4049,48 @@ describe('TypedDocumentNode<Data, Variables>', () => {
},
});
});

it("should infer the types of modifier fields", () => {
const cache = getBookCache();

cache.writeQuery({
query,
variables: ffplVariables,
data: {
book: ffplBook,
},
});

/** Asserts the inferred TypeScript type of a value matches the expected type */
const assertType =
<ExpectedType>() =>
<ActualType extends ExpectedType>(
_value: ActualType
): ExpectedType extends ActualType ? void : never =>
void 0 as any;

cache.modify({
id: cache.identify(ffplBook),
fields: {
isbn: (value) => {
assertType<string>()(value);
return value;
},
title: (value, { INVALIDATE }) => {
assertType<string>()(value);
return INVALIDATE;
},
author: (value, { DELETE, isReference }) => {
assertType<Reference | Book["author"]>()(value);
if (isReference(value)) {
assertType<Reference>()(value);
} else {
assertType<Book["author"]>()(value);
}

return DELETE;
},
} satisfies Modifiers<Book>,
});
});
});
4 changes: 2 additions & 2 deletions src/cache/inmemory/entityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export abstract class EntityStore implements NormalizedCache {

public modify(
dataId: string,
fields: Modifier<any> | Modifiers,
fields: Modifier<any> | Modifiers<Record<string, any>>,
): boolean {
const storeObject = this.lookup(dataId);

Expand Down Expand Up @@ -224,7 +224,7 @@ export abstract class EntityStore implements NormalizedCache {
const fieldName = fieldNameFromStoreName(storeFieldName);
let fieldValue = storeObject[storeFieldName];
if (fieldValue === void 0) return;
const modify: Modifier<StoreValue> = typeof fields === "function"
const modify: Modifier<StoreValue> | undefined = typeof fields === "function"
? fields
: fields[storeFieldName] || fields[fieldName];
if (modify) {
Expand Down
2 changes: 1 addition & 1 deletion src/cache/inmemory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export interface NormalizedCache {
merge(olderId: string, newerObject: StoreObject): void;
merge(olderObject: StoreObject, newerId: string): void;

modify(dataId: string, fields: Modifiers | Modifier<any>): boolean;
modify(dataId: string, fields: Modifiers<Record<string, any>> | Modifier<any>): boolean;
delete(dataId: string, fieldName?: string): boolean;
clear(): void;

Expand Down

0 comments on commit bbc2cea

Please sign in to comment.