Skip to content

feat: Improve TypeScript definitions in the getEntityRecord function - #81863

Open
im3dabasia wants to merge 10 commits into
WordPress:trunkfrom
im3dabasia:fix/ts-improvement-getEntityRecord
Open

feat: Improve TypeScript definitions in the getEntityRecord function#81863
im3dabasia wants to merge 10 commits into
WordPress:trunkfrom
im3dabasia:fix/ts-improvement-getEntityRecord

Conversation

@im3dabasia

@im3dabasia im3dabasia commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What?

No issue

getEntityRecord and getEntityRecords now infer what they return from their kind and name arguments.

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 the Post type. So the return type fell back to a union of all 26 record types:

image

A union only lets you read properties present on every member. title isn't on Base, so post.title failed.

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:

// Asks for a comment. Says it's a Post. TypeScript agreed.
getEntityRecord< Post >( 'root', 'comment', 1 );

So the types were either in the way, or quietly wrong.

How?

  1. A map from kind/name to record type. 29 pairs.
  2. Unknown pairs fall through unchanged. Plugins can extend the map.
  3. ~25 hand-written annotations and casts deleted at call sites.
  4. Autocomplete now offers the real fields.
  5. Naming the wrong type is an error, not a silent pass.

Also in here:

  • { context: 'view' } now types as the view record, not edit.
  • Three record types added: GlobalStyles, WpBlock, WpNavigation.
  • resolveSelect().getEntityRecord() was silently falling back. Fixed.
  • Two nullability bugs surfaced once the casts came off. Both fixed.

Types only. No runtime change.

Testing Instructions

CI covers this, but to see it directly:
  1. npm run typecheck — should pass with no errors.
  2. Create ts-repro/repro.ts at the repo root:
import { store as coreStore } from '@wordpress/core-data';
import { select } from '@wordpress/data';

const post = select( coreStore ).getEntityRecord( 'postType', 'post', 1 );
console.log( post?.title );
  1. Open it in your editor. post is Post< 'edit' >, and post. autocompletes the real fields.
  2. Change 'post' to 'wp_template' — the inferred type follows.
  3. git stash and repeat: the same line now fails with the union error above.
  4. rm -rf ts-repro when done.

Note: npm run test:unit does not validate the type assertions in entity-record-of.test.ts — Jest strips types without checking them. Only npm run typecheck does.

Use of AI Tools

Claude Code.

@github-actions github-actions Bot added [Package] Core data /packages/core-data [Package] Fields /packages/fields labels Aug 20, 2026
@im3dabasia
im3dabasia marked this pull request as ready for review August 20, 2026 10:53
@im3dabasia im3dabasia self-assigned this Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: im3dabasia <im3dabasia1@git.wordpress.org>
Co-authored-by: manzoorwanijk <manzoorwanijk@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@im3dabasia im3dabasia added the [Type] Code Quality Issues or PRs that relate to code quality label Aug 20, 2026
@manzoorwanijk

Copy link
Copy Markdown
Member

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.

@ciampo

ciampo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor
@im3dabasia I don't have much time to review this PR, unfortunately. I will copy-paste the verbatim output of an AI-assisted review session

Summary

The 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 trunk. Static analysis, type checks, and unit-test shards are green. The only failing CI job is an unrelated TinyMCE meta-box Playwright timeout.

1. [major] Reusable query objects silently regain edit-context fields

packages/core-data/src/entity-types/index.ts:267-271

ContextOfQuery defaults to 'edit' unless context is already represented by a narrow Context literal in the query type. This works for an inline object, but a normal reusable object widens the property to string:

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 context=view, so edit-only fields such as content.raw and password can be absent at runtime. Exact-head declaration probes reproduced the same edit inference for { context?: Context } and Record<string, any>, across getEntityRecord, getEntityRecords, and resolveSelect.

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] _fields requests are typed as complete records

packages/core-data/src/entity-types/index.ts:261-277
Runtime evidence: packages/core-data/src/selectors.ts:432-455

EntityRecordOfQuery deliberately ignores _fields and returns the complete record type. The runtime selector does the opposite: it builds and returns an object containing only the requested fields. As a result, this compiles at the exact head:

const post = select( coreStore ).getEntityRecord(
	'postType',
	'post',
	1,
	{ _fields: 'id' }
);

post?.content.raw; // Compiles, but `content` was not requested.

The same issue affects plural and promised selectors. Requiring consumers to add a local Pick does not make the default complete-record type safe. Please return at least a conservative Partial record when _fields is present, or infer a projection if that can stay maintainable. Consumers that need a more precise local shape can still supply one explicitly.

3. [minor] Plugins cannot augment custom names under existing entity kinds

packages/core-data/src/entity-types/index.ts:185-220

The interface is open only at its top level. A plugin can add a new kind such as myPlugin, but it cannot add product to the existing postType object or genre to taxonomy. Redeclaring either property through module augmentation fails with TS2717 because merged interface properties must have identical types. The selector then falls back to the broad record union, so custom post types and taxonomies cannot opt into the new inference.

Please make each kind's name map independently augmentable, for example with separate RootEntityRecordTypes, PostTypeEntityRecordTypes, and TaxonomyEntityRecordTypes interfaces that EntityRecordTypes composes. A flat augmentable pair map would also avoid the nested declaration-merging limit.

4. [minor] The tests do not exercise the public selector overloads

packages/core-data/src/entity-types/test/entity-record-of.test.ts:128-190

The added tests instantiate EntityRecordOf and EntityRecordOfQuery directly. They never call select( coreStore ), resolveSelect( coreStore ), getEntityRecord, or getEntityRecords. This is why both major issues above pass the new suite. The comment at lines 178-181 says selectors.test.ts covers the unknown-pair fallback, but that JavaScript runtime test cannot verify TypeScript overload resolution.

Please add a compile-time public-contract test that covers singular, plural, and promised selectors; known and unknown pairs; inline and reusable query objects; _fields; the retained explicit generic; and module augmentation. That will test the CurriedSignature and PromiseCurriedSignature behavior that consumers actually receive.

@manzoorwanijk manzoorwanijk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Apart from what @ciampo said, I have some inline comments.

Comment thread packages/core-data/src/entity-types/test/entity-record-of.test.ts Outdated
Comment thread packages/core-data/src/entity-types/test/entity-record-of.test.ts Outdated
Comment thread packages/core-data/src/entity-types/test/entity-record-of.test.ts Outdated
Comment thread packages/core-data/src/entity-types/wp-block.ts Outdated
Comment thread packages/core-data/src/entity-types/wp-navigation.ts Outdated
Comment thread packages/core-data/src/entity-types/wp-block.ts Outdated
Comment thread packages/core-data/src/entity-types/index.ts Outdated
Comment thread packages/core-data/src/entity-types/global-styles.ts Outdated
MenuLocation,
NavMenu,
NavMenuItem,
OmitNevers,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't this make it a public export? Do we want to do that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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/editor with TS2883, the inferred type of
    useAvailableTemplates can no longer be named.
  • So it's a consequence of the inference, not a deliberate API expansion.

Comment on lines +65 to +67
PostStatus,
PostStatusObject,
RenderedText,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here, these now become public.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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/editor with TS2883, the inferred type of
    useAvailableTemplates can no longer be named.
  • So it's a consequence of the inference, not a deliberate API expansion.

@im3dabasia

im3dabasia commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@ciampo and @manzoorwanijk

On _fields: leaving this untyped was deliberate, not an oversight:

  • kind and name are a closed set that we can enumerate. _fields isn', it's any subset of a record's fields, plus whatever a plugin registers, as a comma-separated string. Typing it end to end couples the return type to the literal at each call site and requires a proper design pass for the custom post type and plugin cases first.
  • There are ~10 call sites, compared with the ~293 that the kind/name inference reaches. Those ten already state their own shape locally, for example fields/author annotates the result rather than relying on the selector. That's the existing convention, not something this PR introduces.

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 _fields. Happy to file that issue if you agree.

@manzoorwanijk

Copy link
Copy Markdown
Member

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 _fields.

Sounds good to me

@github-actions github-actions Bot added the [Package] Boot /packages/boot label Aug 28, 2026
@manzoorwanijk

Copy link
Copy Markdown
Member

Let us update the branch from trunk

@ciampo ciampo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Left a few more comments, mostly discovered with the help of an AI agent

Comment on lines +329 to +351
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 > >;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +317 to +319
type ContextOfQuery< Query > = 'context' extends keyof Query
? ContextsOf< Query[ 'context' & keyof Query ] >
: 'edit';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Comment on lines +79 to +111
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
>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like these types don't match the REST schema?

-content.is_protected instead of content.protected

Comment on lines +60 to +89
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
>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like these types don't match the REST schema?

-content.is_protected instead of content.protected

Comment on lines +356 to +365
<
EntityRecord extends
| ET.EntityRecord< any >
| Partial< ET.EntityRecord< any > >,
>(
kind: string,
name: string,
key?: EntityRecordKey,
query?: GetRecordsHttpQuery
): EntityRecord | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Comment on lines +384 to +393
<
EntityRecord extends
| ET.EntityRecord< any >
| Partial< ET.EntityRecord< any > >,
>(
kind: string,
name: string,
key?: EntityRecordKey,
query?: GetRecordsHttpQuery
): Promise< EntityRecord | undefined >;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as previous comment

Comment on lines +696 to +725
<
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 >;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as previous comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Boot /packages/boot [Package] Core data /packages/core-data [Package] Fields /packages/fields [Type] Code Quality Issues or PRs that relate to code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants