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

Entity-aware type signature for getEntityRecord and getEntityRecords #41235

Merged
merged 9 commits into from
Jun 4, 2022

Conversation

adamziel
Copy link
Contributor

@adamziel adamziel commented May 23, 2022

What problem does this PR solve?

A subset of #39025

This PR proposes a type signature for the getEntityRecord and getEntityRecords selectors that supports the following use-cases:

const commentDefault = getEntityRecord( {} as State, 'root', 'comment', 15 );
// commentDefault is Comment<'edit'>

const commentView = getEntityRecord( {} as State, 'root', 'comment', 15, {
	context: 'view',
} );
// commentView is Comment<'view'>

const commentInvalidPK = getEntityRecord(
	{} as State,
	'root',
	'comment',
	'15'
);
// commentInvalidPK shows a TypeScript error

const commentView2 = getEntityRecord( {} as State, 'root', 'comment', 15, {
	context: 'view',
	_fields: [ 'id' ],
} );
// commentView is Partial< Comment<'view'> >

And analogous use-cases for getEntityRecords.

Testing Instructions

  • Confirm the tests are green, except for what I mentioned above
  • Confirm the types work as described above
  • Confirm the types read well and make sense

cc @dmsnell @sarayourfriend @sirreal

@adamziel adamziel added [Type] Code Quality Issues or PRs that relate to code quality [Package] Core data /packages/core-data Developer Experience Ideas about improving block and theme developer experience labels May 23, 2022
@adamziel adamziel requested a review from nerrad as a code owner May 23, 2022 11:52
@adamziel adamziel self-assigned this May 23, 2022

// createSelector isn't properly typed if I don't explicitly import these files – ideally they would
// be merely ambient definitions that TS is aware of.
import type {} from './rememo';
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'm not sure what's the best way of using this ambient type without TS warnings before enabling "types" in package.json – these two seem to be connected.

Copy link
Member

Choose a reason for hiding this comment

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

Might be worth finally submitting a patch to rememo which adds a types.d.ts for it, or submitting a new package to the DefinitelyTyped repo. We keep getting back to the point of adding workarounds for it and I think now is as good of a time as ever to make the types real - they aren't that hard to write.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding types.d.ts would work but I worry it would also create the need to revisit/remove all the jsDoc annotations. I went for a lazy approach of merely adding a @return annotation. It did the trick in my local testing:

aduth/rememo#7

In the meantime, I introduced typed-rememo.ts to this PR so that we have a path forward in case merging that PR takes a long time.

Copy link
Member

@aduth aduth May 27, 2022

Choose a reason for hiding this comment

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

Looks like y'all are on an older version of rememo (v3.0.0). First-party type definitions are available as of v4.0.0, so should hopefully be as simple as upgrading.

image

Copy link
Member

Choose a reason for hiding this comment

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

Since it's used across many different packages, I opened a separate pull request to upgrade them all in one go: #41415

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 went ahead and merged that PR, thank you so much @aduth! I am AFK this week so I will rebase this one in a few days to confirm it went well.

Copy link
Member

Choose a reason for hiding this comment

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

Oh hi :)

*
* For more details, visit https://github.com/microsoft/TypeScript/issues/23132
*/
return filteredItem as any;
Copy link
Member

Choose a reason for hiding this comment

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

/me looks suspiciously at this comment

going to have to think about this a little. could we get around this by supplying two function type declarations? one in which fields is a string[] and which returns Partial<…> and another in which fields is missing and which returns EntityRecord<…>?

Copy link
Contributor Author

@adamziel adamziel May 25, 2022

Choose a reason for hiding this comment

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

!! I like this idea! I just got it to work in TS playground:

type NumberToNumber = (arg:number) => number;
type StringToString = (arg:string) => string;

const eitherFunction = {} as NumberToNumber & StringToString;
const numberHopefully = eitherFunction(1);
//    ^? number
const stringHopefully = eitherFunction('abc');
//    ^? string

I'll try to apply this to the selector signature as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@dmsnell I ended up getting nowhere with the type intersection operator. The signature overloading via the interface declaration as follows:

interface GetEntityRecord {
	<
		R extends EntityRecordOf< K, N >,
		C extends Context = DefaultContextOf< R >,
		K extends Kind = KindOf< R >,
		N extends Name = NameOf< R >
	>(
		state: State,
		kind: K,
		name: N,
		key: KeyOf< K, N >,
		query: EntityQuery< C >
	): Partial< EntityRecordOf< K, N, C > > | null | undefined;

	<
		R extends EntityRecordOf< K, N >,
		C extends Context = DefaultContextOf< R >,
		K extends Kind = KindOf< R >,
		N extends Name = NameOf< R >
	>(
		state: State,
		kind: K,
		name: N,
		key: KeyOf< K, N >,
		query?: Omit< EntityQuery< C >, '_fields' >
	): EntityRecordOf< K, N, C > | null | undefined;
}

const getEntityRecordImplementation: GetEntityRecord = <
	R extends EntityRecordOf< K, N >,
	C extends Context = DefaultContextOf< R >,
	K extends Kind = KindOf< R >,
	N extends Name = NameOf< R >
>(
	state: State,
	kind: K,
	name: N,
	key: KeyOf< R >,
	query?: EntityQuery< C >
) => {};

I don't love the proliferation of types here, but it does a good job of distinguishing between the two return types. Trade-offs, trade-offs everywhere 🤷 I've also inlined the getEntityRecordImplementation 1bd4111

✅ One declaration less
as GetEntityRecord doesn't give us the same type safety as : GetEntityRecord

@adamziel
Copy link
Contributor Author

adamziel commented May 26, 2022

@dmsnell I went for the getting the dependency graph of getEntityRecord to work and then also introduced the getEntityRecords type signature. I hoped to see all green checks, but there are some docgen issues yet again:

Error: Command failed with exit code 1: "/Users/cloudnik/www/Automattic/core/plugins/gutenberg/node_modules/.bin/docgen" packages/core-data/src/selectors.ts --output docs/reference-guides/data/data-core.md --to-token --use-token "Autogenerated selectors|../../../packages/core-data/src/selectors.ts" --ignore "/unstable|experimental/i"

TypeError: Cannot read property '0' of undefined
  at makeError (/Users/cloudnik/www/Automattic/core/plugins/gutenberg/node_modules/execa/lib/error.js:59:11)
  at handlePromise (/Users/cloudnik/www/Automattic/core/plugins/gutenberg/node_modules/execa/index.js:114:26)

The error is gone when the selector declaration is of form:

export const getEntityRecord = createSelector(
	< /* generics */ >( state, /* arguments */ ) => {},
	() => {}
);

But fails with the following forms:

export const getEntityRecord = createSelector(
	(< /* generics */ >( state, /* arguments */ ) => {}) as GetEntityRecord,
	() => {}
);
export const getEntityRecord = createSelector(
	getEntityRecordImplementation,
	() => {}
);

So we may need to revisit docgen once again.

adamziel added a commit to adamziel/rememo that referenced this pull request May 26, 2022
## What problem does this PR solve?

Right now, the `createSelector` function loses the selector type details:

```ts
const iReturnNumbers = createSelector(
	( stringArg: string ) => 123,
	() => []
);
const iShouldBeANumber = iReturnNumbers();
// iShouldBeANumber is of type `any`
```

This is a problem in Gutenberg which depends on this package. For example, see the following PR: WordPress/gutenberg#41235 (comment)

## How does this PR propose to solve this problem?

Adding the following `@return` type annotation preserves the selector type signature:

```
 * @return {S} Memoized selector.
```

The above snippet now works as expected:

```ts
const iReturnNumbers = createSelector(
	( stringArg: string ) => 123,
	() => []
);
const iShouldBeANumber = iReturnNumbers();
// iShouldBeANumber is of type `number`
```

## Test plan

This changes just the documentation string, there are no runtime changes to test. Eyeballing the changes and confirming the types are now resolved as in the above examples should suffice.

Caveat: this could be considered a breaking change for any projects using `createSelector` in TypeScript. After upgrading rememo to a patched version, the selectors will be of a different type, which could cause type errors. This could be addressed by releasing a new major point release.

cc @aduth @dmsnell
@adamziel adamziel changed the title Entity-aware type signature for getEntityRecord Entity-aware type signature for getEntityRecord and getEntityRecords May 26, 2022
}

type RecordKey = number | string;
type GenericRecordKey = number | string;
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 retained this for now, but ideally we'll get rid of this type in a follow-up PR that will clean up the types in this file and add any missing signatures.

@dmsnell
Copy link
Member

dmsnell commented May 26, 2022

Concerning docgen I think we can try/catch on createSelector and return any if we don't get it. That's unfortunate, but I think I'm at the end of where I want to special-case that function.

Is the problem (a) that as Type alters the AST which we have hard-coded to match and (b) that passing a function as a value isn't a function expression in the AST?

@adamziel
Copy link
Contributor Author

Is the problem (a) that as Type alters the AST which we have hard-coded to match and (b) that passing a function as a value isn't a function expression in the AST?

That’s my understanding, although I did not debug it

@aduth aduth mentioned this pull request May 27, 2022
name: N,
key: KeyOf< K, N >,
query: EntityQuery< C >
): Partial< EntityRecordOf< K, N, C > > | null | undefined;
Copy link
Member

Choose a reason for hiding this comment

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

It would be nice if it were a compile time error to refer to a field that you didn't select.

const stuff = core.getEntityRecords( 'root', 'comment', { _fields: [ 'id', 'content' ] } );
console.log( stuff[ 0 ].title ); // shouldn't compile

Maybe this is possible by having the user specify (again...) which fields they are selecting as a type param.

const stuff = core.getEntityRecords< 'id' | 'content' >( 'root', 'comment', { _fields: [ 'id', 'content' ] } );
console.log( stuff[ 0 ].title ); // shouldn't compile

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes it would! I hope to make it happen in one of the next iterations – let's keep this PR at atomic as possible.

packages/core-data/src/selectors.ts Outdated Show resolved Hide resolved
@adamziel adamziel force-pushed the ts/get-entity-record-signature branch from ec6058a to fc343d4 Compare June 2, 2022 13:05
@noisysocks
Copy link
Member

Very exciting and mind bending stuff! Thanks for running me through everything at WCEU.

I plopped this into the bottom of selectors.ts and TypeScript tells me that it thinks userView is of type User<'edit'> which ain't right – should be User<'view'>.

const userView = getEntityRecord( {} as State, 'root', 'user', 123, {
	context: 'view',
} );

@adamziel
Copy link
Contributor Author

adamziel commented Jun 2, 2022

@noisysocks Turns out it was caused by using Omit< EntityQuery, '_fields' > in one of the overloaded function signatures. I switched to a different way of matching a query with/without _fields and it seems to all work now!

@@ -305,7 +365,12 @@ export const getEntityRecord = createSelector(
];
}
);
const commentDefault = getEntityRecord( {} as State, 'root', 'comment', 15 );
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 left this accidentally, let’s remove it

Copy link
Member

@noisysocks noisysocks left a comment

Choose a reason for hiding this comment

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

LGTM once tests pass! 🙏

@adamziel adamziel merged commit 93cb857 into trunk Jun 4, 2022
@adamziel adamziel deleted the ts/get-entity-record-signature branch June 4, 2022 07:15
@github-actions github-actions bot added this to the Gutenberg 13.5 milestone Jun 4, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Developer Experience Ideas about improving block and theme developer experience [Package] Core data /packages/core-data [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants