feat: Improve TypeScript definitions in the getEntityRecord function - #81863
feat: Improve TypeScript definitions in the getEntityRecord function#81863im3dabasia wants to merge 10 commits into
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
…d to fix/ts-improvement-getEntityRecord
|
This looks interesting! Thank you for taking this on. I am currently travelling back from WordCamp US and will try to get back to this in one of my flights with good WiFi or next week after I am back home. |
@im3dabasia I don't have much time to review this PR, unfortunately. I will copy-paste the verbatim output of an AI-assisted review sessionSummaryThe inferred entity map is a useful improvement, and the retained fallback overloads preserve existing explicit-generic and custom-entity callers. I do not think this head is ready to merge, however. Two query shapes produce types that claim fields the REST response does not contain. The new extension map and its tests also miss common plugin and selector-level cases. The PR currently conflicts with 1. [major] Reusable query objects silently regain edit-context fields
const query = { context: 'view' };
const post = select( coreStore ).getEntityRecord(
'postType',
'post',
1,
query
);
post?.content.raw; // Compiles because `post` is inferred as `Post<'edit'>`.The request still uses Please keep the precise result for literal contexts, but return a conservative union of the possible context records when the query's context is widened or optional. Add public-selector type tests for inline, reusable, optional-context, and broad query objects so these cases cannot silently fall back to edit. 2. [major]
|
manzoorwanijk
left a comment
There was a problem hiding this comment.
Apart from what @ciampo said, I have some inline comments.
| MenuLocation, | ||
| NavMenu, | ||
| NavMenuItem, | ||
| OmitNevers, |
There was a problem hiding this comment.
Doesn't this make it a public export? Do we want to do that?
There was a problem hiding this comment.
They do, and it turns out they have to be:
- The selectors now return the concrete record type instead of the broad union, so
consumers' inferred types are built from these helpers. - Dropping them fails
@wordpress/editorwith TS2883, the inferred type of
useAvailableTemplatescan no longer be named. - So it's a consequence of the inference, not a deliberate API expansion.
| PostStatus, | ||
| PostStatusObject, | ||
| RenderedText, |
There was a problem hiding this comment.
Same here, these now become public.
There was a problem hiding this comment.
They do, and it turns out they have to be:
- The selectors now return the concrete record type instead of the broad union, so
consumers' inferred types are built from these helpers. - Dropping them fails
@wordpress/editorwith TS2883, the inferred type of
useAvailableTemplatescan no longer be named. - So it's a consequence of the inference, not a deliberate API expansion.
|
On
So I'd keep this PR focused on the first two arguments, which have been incorrectly typed for a long time, and open a follow-up for |
Sounds good to me |
|
Let us update the branch from trunk |
ciampo
left a comment
There was a problem hiding this comment.
Left a few more comments, mostly discovered with the help of an AI agent
| type EntityRecordInContexts< | ||
| Kind extends EntityKind, | ||
| Name extends EntityNameOf< Kind >, | ||
| C extends Context, | ||
| > = C extends Context ? EntityRecordOf< Kind, Name, C > : never; | ||
|
|
||
| /** | ||
| * Resolves a `kind`/`name` pair against the query it was requested with. | ||
| * | ||
| * `context` selects which fields the REST API serialises, so a `'view'` | ||
| * request must not be typed with the edit-context fields. | ||
| * | ||
| * `_fields` is deliberately not modelled. Narrowing to the named fields makes | ||
| * the type rigid for what is a small number of call sites, and the useful | ||
| * shape there varies per consumer. Call sites that request a subset and want | ||
| * that reflected should say so locally -- with `Pick`, or their own interface | ||
| * -- rather than have it imposed here. | ||
| */ | ||
| export type EntityRecordOfQuery< | ||
| Kind extends EntityKind, | ||
| Name extends EntityNameOf< Kind >, | ||
| Query, | ||
| > = EntityRecordInContexts< Kind, Name, ContextOfQuery< Query > >; |
There was a problem hiding this comment.
RenderedText<'view'> keeps raw: never, so the context union reduces never | string to string.
A reusable { context: 'view' } query therefore lets callers read post.title.raw, although the response can omit it.
We should potentially remove nested edit-only properties from non-edit records, and update the test at packages/core-data/src/entity-types/test/types.ts:324-334 to reject this access.
| type ContextOfQuery< Query > = 'context' extends keyof Query | ||
| ? ContextsOf< Query[ 'context' & keyof Query ] > | ||
| : 'edit'; |
There was a problem hiding this comment.
For { context: 'view' } | { per_page: number }, keyof Query excludes context, so the result becomes Post<'edit'>.
TypeScript then allows record.password even when the request uses context=view.
We should likely resolve each union member separately (and ideally add public-selector tests for this shape)
| content: ContextualField< | ||
| { | ||
| raw: string; | ||
| /** | ||
| * Whether the content is protected with a password. | ||
| */ | ||
| is_protected: boolean; | ||
| /** | ||
| * Version of the content block format used by the pattern. | ||
| */ | ||
| block_version: ContextualField< string, 'edit', C >; | ||
| }, | ||
| 'view' | 'edit', | ||
| C | ||
| >; | ||
| /** | ||
| * The excerpt for the pattern. | ||
| */ | ||
| excerpt: RenderedText< C > & { | ||
| protected: boolean; | ||
| }; | ||
| /** | ||
| * Meta fields. `wp_pattern_sync_status` is registered by core on | ||
| * this post type; an absent value means the pattern is fully | ||
| * synced. | ||
| */ | ||
| meta: ContextualField< | ||
| { | ||
| wp_pattern_sync_status?: 'partial' | 'unsynced'; | ||
| } & Record< string, unknown >, | ||
| 'view' | 'edit', | ||
| C | ||
| >; |
There was a problem hiding this comment.
Looks like these types don't match the REST schema?
-content.is_protected instead of content.protected
block_versionasstringinstead ofnumberwp_pattern_sync_statusinmetainstead of at the top level
| status: ContextualField< PostStatus, 'view' | 'edit', C >; | ||
| /** | ||
| * Type of post. | ||
| */ | ||
| type: string; | ||
| /** | ||
| * A password to protect access to the content. | ||
| */ | ||
| password: ContextualField< string, 'edit', C >; | ||
| /** | ||
| * The title for the navigation menu. | ||
| */ | ||
| title: RenderedText< C >; | ||
| /** | ||
| * The content for the navigation menu. | ||
| */ | ||
| content: ContextualField< | ||
| RenderedText< C > & { | ||
| /** | ||
| * Whether the content is protected with a password. | ||
| */ | ||
| is_protected: boolean; | ||
| /** | ||
| * Version of the content block format used by the menu. | ||
| */ | ||
| block_version: ContextualField< string, 'edit', C >; | ||
| }, | ||
| 'view' | 'edit', | ||
| C | ||
| >; |
There was a problem hiding this comment.
Looks like these types don't match the REST schema?
-content.is_protected instead of content.protected
block_versionasstringinstead ofnumber- embed contexts added by Core are omitted
| < | ||
| EntityRecord extends | ||
| | ET.EntityRecord< any > | ||
| | Partial< ET.EntityRecord< any > >, | ||
| >( | ||
| kind: string, | ||
| name: string, | ||
| key?: EntityRecordKey, | ||
| query?: GetRecordsHttpQuery | ||
| ): EntityRecord | undefined; |
There was a problem hiding this comment.
The fallback overloads still accept getEntityRecord<Post>( 'root', 'comment', 1 ), contrary to the PR description. The test covers explicit generics only for unknown plugin entities.
We should likely restrict the fallback to unknown pairs (and ideally add negative tests for known mismatches, including plural and resolveSelect calls)
| < | ||
| EntityRecord extends | ||
| | ET.EntityRecord< any > | ||
| | Partial< ET.EntityRecord< any > >, | ||
| >( | ||
| kind: string, | ||
| name: string, | ||
| key?: EntityRecordKey, | ||
| query?: GetRecordsHttpQuery | ||
| ): Promise< EntityRecord | undefined >; |
| < | ||
| EntityRecord extends | ||
| | ET.EntityRecord< any > | ||
| | Partial< ET.EntityRecord< any > >, | ||
| >( | ||
| kind: string, | ||
| name: string, | ||
| query?: GetRecordsHttpQuery | ||
| ): EntityRecord[] | null; | ||
| }; | ||
|
|
||
| PromiseCurriedSignature: < | ||
| EntityRecord extends | ||
| | ET.EntityRecord< any > | ||
| | Partial< ET.EntityRecord< any > >, | ||
| >( | ||
| kind: string, | ||
| name: string, | ||
| query?: GetRecordsHttpQuery | ||
| ) => Promise< EntityRecord[] | null >; | ||
| PromiseCurriedSignature: { | ||
| < | ||
| Kind extends ET.EntityKind, | ||
| Name extends ET.EntityNameOf< Kind >, | ||
| const Query extends GetRecordsHttpQuery | undefined = undefined, | ||
| >( | ||
| kind: Kind, | ||
| name: Name, | ||
| query?: Query | ||
| ): Promise< ET.EntityRecordOfQuery< Kind, Name, Query >[] | null >; | ||
| < | ||
| EntityRecord extends | ||
| | ET.EntityRecord< any > | ||
| | Partial< ET.EntityRecord< any > >, | ||
| >( | ||
| kind: string, | ||
| name: string, | ||
| query?: GetRecordsHttpQuery | ||
| ): Promise< EntityRecord[] | null >; |
What?
No issue
getEntityRecordandgetEntityRecordsnow infer what they return from theirkindandnamearguments.Why?
getEntityRecord( 'postType', 'post', id )names what it wants with two strings.A human reads that and knows a Post is coming back. TypeScript didn't.
Both parameters were typed as plain
string. Nothing tied the value'post'to thePosttype. So the return type fell back to a union of all 26 record types:A union only lets you read properties present on every member.
titleisn't onBase, sopost.titlefailed.That affected 293 call sites.
The workaround was to name the type by hand —
getEntityRecord< Post >( … ). But that's an unchecked assertion, not a check. This compiled fine:So the types were either in the way, or quietly wrong.
How?
kind/nameto record type. 29 pairs.Also in here:
{ context: 'view' }now types as the view record, not edit.GlobalStyles,WpBlock,WpNavigation.resolveSelect().getEntityRecord()was silently falling back. Fixed.Types only. No runtime change.
Testing Instructions
CI covers this, but to see it directly:
npm run typecheck— should pass with no errors.ts-repro/repro.tsat the repo root:postisPost< 'edit' >, andpost.autocompletes the real fields.'post'to'wp_template'— the inferred type follows.git stashand repeat: the same line now fails with the union error above.rm -rf ts-reprowhen done.Note:
npm run test:unitdoes not validate the type assertions inentity-record-of.test.ts— Jest strips types without checking them. Onlynpm run typecheckdoes.Use of AI Tools
Claude Code.