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

Improve TypeScript type-safety of cache.modify #10895

Merged
merged 10 commits into from
Jun 13, 2023
24 changes: 24 additions & 0 deletions .changeset/afraid-zebras-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@apollo/client": minor
---

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

Example:

```ts
cache.modify<Book>({
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`.
},
},
});
```
9 changes: 9 additions & 0 deletions .changeset/cold-tips-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@apollo/client": minor
---

Use unique opaque types for the `DELETE` and `INVALIDATE` Apollo cache modifiers.

This increases type safety, since these 2 modifiers no longer have the `any` type.
Moreover, it no longer triggers [the `@typescript-eslint/no-unsafe-return`
rule](https://typescript-eslint.io/rules/no-unsafe-return/).
141 changes: 140 additions & 1 deletion src/cache/core/__tests__/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
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 am afraid tests are excluded from typechecking. I intentionally created a typechecking error and npm run build passed

image

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)

Copy link
Member

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

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 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 🙂

Copy link
Member

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!

Copy link
Contributor Author

@Gelio Gelio Jun 21, 2023

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.

class TestCache extends ApolloCache<unknown> {
constructor() {
super();
Expand Down Expand Up @@ -308,3 +308,142 @@ describe('abstract cache', () => {
});
});
});

describe.skip('Cache type tests', () => {
describe('modify', () => {
test('field types are inferred correctly from passed entity type', () => {
const cache = new TestCache();
cache.modify<{
prop1: string;
prop2: number;
child: {
someObject: true
},
children: {
anotherObject: false
}[]
}>({
fields: {
prop1(field) {
expectTypeOf(field).toEqualTypeOf<string>();
return field;
},
prop2(field) {
expectTypeOf(field).toEqualTypeOf<number>();
return field;
},
child(field) {
expectTypeOf(field).toEqualTypeOf<{ someObject: true } | Reference>();
return field;
},
children(field) {
expectTypeOf(field).toEqualTypeOf<(ReadonlyArray<{ anotherObject: false }>) | ReadonlyArray<Reference>>();
return field;
}
}
})
})
test('field method needs to return a value of the correct type', () => {
const cache = new TestCache();
cache.modify<{ p1: string, p2: string, p3: string, p4: string, p5: string }>({
fields: {
p1() { return "" },
// @ts-expect-error returns wrong type
p2() { return 1 },
// @ts-expect-error needs return statement
p3() {},
p4(_, { DELETE }) { return DELETE },
p5(_, { INVALIDATE }) { return INVALIDATE },
}
})
})
test('passing a function as `field` should infer all entity properties as possible input (interfaces)', () => {
interface ParentEntity {
prop1: string;
prop2: number;
child: ChildEntity;
}
interface ChildEntity {
prop1: boolean;
prop2: symbol;
children: OtherChildEntry[];
}
interface OtherChildEntry {
foo: false
}

const cache = new TestCache();
// with reference
cache.modify<ParentEntity>({
id: 'foo',
fields(field) {
expectTypeOf(field).toEqualTypeOf<string | number | ChildEntity | Reference>();
return field;
}
})
// without reference
cache.modify<ChildEntity>({
id: 'foo',
fields(field) {
expectTypeOf(field).toEqualTypeOf<boolean | symbol | readonly OtherChildEntry[] | readonly Reference[]>();
return field;
}
})
})
test('passing a function as `field` should infer all entity properties as possible input (types)', () => {
type ParentEntity = {
prop1: string;
prop2: number;
child: ChildEntity;
}
type ChildEntity = {
prop1: boolean;
prop2: symbol;
children: OtherChildEntry[];
}
type OtherChildEntry = {
foo: false
}

const cache = new TestCache();
// with reference
cache.modify<ParentEntity>({
id: 'foo',
fields(field) {
expectTypeOf(field).toEqualTypeOf<string | number | ChildEntity | Reference>();
return field;
}
})
// without reference
cache.modify<ChildEntity>({
id: 'foo',
fields(field) {
expectTypeOf(field).toEqualTypeOf<boolean | symbol | readonly OtherChildEntry[] | readonly Reference[]>();
return field;
}
})
})
test('passing a function as `field` w/o specifying an entity type', () => {
const cache = new TestCache();
cache.modify({
id: 'foo',
fields(field) {
expectTypeOf(field).toEqualTypeOf<any>();
return field;
}
});
});
test('passing a function as `field` property w/o specifying an entity type', () => {
const cache = new TestCache();
cache.modify({
id: 'foo',
fields: {
p1(field) {
expectTypeOf(field).toEqualTypeOf<any>();
return field;
}
}
});
});
});
});
2 changes: 1 addition & 1 deletion src/cache/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
return [];
}

public modify(options: Cache.ModifyOptions): boolean {
public modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache.ModifyOptions<Entity>): boolean {
return false;
}

Expand Down
6 changes: 3 additions & 3 deletions src/cache/core/types/Cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DataProxy } from './DataProxy';
import type { Modifier, Modifiers } from './common';
import type { AllFieldsModifier, Modifiers } from './common';;
import type { ApolloCache } from '../cache';

export namespace Cache {
Expand Down Expand Up @@ -57,9 +57,9 @@ export namespace Cache {
discardWatches?: boolean;
}

export interface ModifyOptions {
export interface ModifyOptions<Entity extends Record<string, any> = Record<string, any>> {
id?: string;
fields: Modifiers | Modifier<any>;
fields: Modifiers<Entity> | AllFieldsModifier<Entity>;
optimistic?: boolean;
broadcast?: boolean;
}
Expand Down
39 changes: 32 additions & 7 deletions src/cache/core/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,14 @@ export type ToReferenceFunction = (

export type CanReadFunction = (value: StoreValue) => boolean;

declare const _deleteModifier: unique symbol;
export interface DeleteModifier { [_deleteModifier]: true }
declare const _invalidateModifier: unique symbol;
export interface InvalidateModifier { [_invalidateModifier]: true}

export type ModifierDetails = {
DELETE: any;
INVALIDATE: any;
DELETE: DeleteModifier;
INVALIDATE: InvalidateModifier;
fieldName: string;
storeFieldName: string;
readField: ReadFieldFunction;
Expand All @@ -88,8 +93,28 @@ export type ModifierDetails = {
storage: StorageType;
}

export type Modifier<T> = (value: T, details: ModifierDetails) => T;

export type Modifiers = {
[fieldName: string]: Modifier<any>;
};
export type Modifier<T> = (
value: T,
details: ModifierDetails
) => T | DeleteModifier | InvalidateModifier;

type StoreObjectValueMaybeReference<StoreVal> =
StoreVal extends Record<string, any>[]
? Readonly<StoreVal> | readonly Reference[]
: StoreVal extends Record<string, any>
? StoreVal | Reference
: StoreVal;

export type AllFieldsModifier<
Gelio marked this conversation as resolved.
Show resolved Hide resolved
Entity extends Record<string, any>
> = Modifier<Entity[keyof Entity] extends infer Value ?
StoreObjectValueMaybeReference<Exclude<Value, undefined>>
: never>;
Gelio marked this conversation as resolved.
Show resolved Hide resolved

export type Modifiers<
T extends Record<string, any> = Record<string, unknown>
> = Partial<{
[FieldName in keyof T]: Modifier<
StoreObjectValueMaybeReference<Exclude<T[FieldName], undefined>>
>;
}>;