From 7bb77266fd566fb18363f12e604f2d8a2ec54c52 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 28 May 2026 19:50:06 +0000 Subject: [PATCH 1/6] feat!: always generate block interface --- docs/fields/blocks.mdx | 18 +++++++------- docs/migration-guide/v4.mdx | 8 +++++++ docs/typescript/generating-types.mdx | 4 +++- packages/payload/src/fields/config/types.ts | 14 +++++++---- .../src/utilities/configToJSONSchema.ts | 24 +++++++++++++------ 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index bb4db41866c..f3b43b7818c 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -194,15 +194,15 @@ Blocks are defined as separate configs of their own. trivializes their reusability. -| Option | Description | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`slug`** \* | Identifier for this block type. Will be saved on each block as the `blockType` property. | -| **`fields`** \* | Array of fields to be stored in this block. | -| **`labels`** | Customize the block labels that appear in the Admin dashboard. Auto-generated from slug if not defined. Alternatively you can use `admin.components.Label` for greater control. | -| **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). | -| **`graphQL.singularName`** | Text to use for the GraphQL schema name. Auto-generated from slug if not defined. NOTE: this is set for deprecation, prefer `interfaceName`. | -| **`dbName`** | Custom table name for this block type when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from slug if not defined. | -| **`custom`** | Extension point for adding custom data (e.g. for plugins) | +| Option | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **`slug`** \* | Identifier for this block type. Will be saved on each block as the `blockType` property. | +| **`fields`** \* | Array of fields to be stored in this block. | +| **`labels`** | Customize the block labels that appear in the Admin dashboard. Auto-generated from slug if not defined. Alternatively you can use `admin.components.Label` for greater control. | +| **`interfaceName`** | Override the name of the auto-generated top-level [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). Blocks always get an interface — defaults to a PascalCase form of the slug. | +| **`graphQL.singularName`** | Text to use for the GraphQL schema name. Auto-generated from slug if not defined. NOTE: this is set for deprecation, prefer `interfaceName`. | +| **`dbName`** | Custom table name for this block type when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from slug if not defined. | +| **`custom`** | Extension point for adding custom data (e.g. for plugins) | _\* An asterisk denotes that a property is required._ diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 4ea68268c3c..6fc68187fdc 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -329,6 +329,14 @@ The deprecated `HTMLConverterFeature`, `lexicalHTML`, and the per-node `converte Custom nodes that define `converters.html` should export their converters instead. +### Every block now auto-generates a top-level TypeScript interface + +Before, a block's fields only became a top-level interface in `payload-types.ts` if you set `interfaceName` on the block. Without it, the fields were inlined wherever the block appeared in your types. + +Now **every block always emits a top-level interface**. The name comes from the slug, converted to PascalCase (`'content-block'` → `ContentBlock`, `'richTextBlock'` → `RichTextBlock`). `interfaceName` still works, but it's now an *override* for that default name rather than the switch that enables generation. + +If a block's auto-derived name collides with another type in your generated file (a collection, an array's `interfaceName`, etc.), set `interfaceName` on the block to disambiguate. + ### Jobs: `jobs.depth` and `jobs.runHooks` have been removed ([#16414](https://github.com/payloadcms/payload/pull/16414)) The deprecated `jobs.depth` and `jobs.runHooks` config options have been removed. Job operations now always use direct database calls with depth `0`. diff --git a/docs/typescript/generating-types.mdx b/docs/typescript/generating-types.mdx index c3eb6bbc491..ded2d9c3e8a 100644 --- a/docs/typescript/generating-types.mdx +++ b/docs/typescript/generating-types.mdx @@ -186,7 +186,9 @@ export interface Post { ## Custom Field Interfaces -For `array`, `block`, `group` and named `tab` fields, you can generate top level reusable interfaces. The following group field config: +`array`, `group` and named `tab` fields generate a top-level interface only when you set `interfaceName` on them. `block` fields always generate a top-level interface — `interfaceName` on a block is an **override** of the auto-derived name (which is a PascalCase form of the slug: `'content-block'` → `ContentBlock`). + +The following group field config: ```ts { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3ef4c8f50b8..e14a6e34e66 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1522,11 +1522,17 @@ export type Block = { * @deprecated Use `admin.images` instead. Preferred aspect ratio of the image is 3:2. */ imageURL?: string - /** Customize generated GraphQL and Typescript schema names. - * The slug is used by default. + /** + * Override the name of the top-level TypeScript interface and GraphQL + * type generated for this block. Blocks **always** generate a top-level + * interface — by default it's a PascalCase form of the slug + * (`'content-block'` → `ContentBlock`). Set this to take control of the + * generated name (useful for disambiguating slug-PascalCase collisions + * or referencing the type elsewhere under a name of your choosing). * - * This is useful if you would like to generate a top level type to share amongst collections/fields. - * **Note**: Top level types can collide, ensure they are unique amongst collections, arrays, groups, blocks, tabs. + * **Note**: Top-level types share a namespace with collections, arrays, + * groups, tabs, and other blocks — set an explicit `interfaceName` to + * resolve any collisions. */ interfaceName?: string jsx?: BlockJSX diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 7234a9615b6..100fd663bd0 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -11,7 +11,7 @@ import { MissingEditorProp } from '../errors/MissingEditorProp.js' import { fieldAffectsData } from '../fields/config/types.js' import { generateJobsJSONSchemas } from '../queues/config/generateJobsJSONSchemas.js' import { flattenAllFields } from './flattenAllFields.js' -import { formatNames } from './formatLabels.js' +import { formatNames, toWords } from './formatLabels.js' import { getCollectionIDFieldTypes } from './getCollectionIDFieldTypes.js' import { optionsAreEqual } from './optionsAreEqual.js' @@ -437,7 +437,7 @@ export function fieldsToJSONSchema( if (!opts.forceInlineBlocks) { return { - $ref: `#/definitions/${resolvedBlock.interfaceName ?? resolvedBlock.slug}`, + $ref: `#/definitions/${resolvedBlock.interfaceName ?? toWords(resolvedBlock.slug, true)}`, } } @@ -464,11 +464,15 @@ export function fieldsToJSONSchema( required: ['blockType', ...blockFieldSchemas.required], } - if (!opts.forceInlineBlocks && block.interfaceName) { - interfaceNameDefinitions.set(block.interfaceName, blockSchema) + if (!opts.forceInlineBlocks) { + // Always register the block as a top-level definition, + // using the user's `interfaceName` override if set or a + // PascalCase fallback derived from the slug. + const interfaceName = block.interfaceName ?? toWords(block.slug, true) + interfaceNameDefinitions.set(interfaceName, blockSchema) return { - $ref: `#/definitions/${block.interfaceName}`, + $ref: `#/definitions/${interfaceName}`, } } @@ -1262,7 +1266,11 @@ export function configToJSONSchema( i18n?: I18n, opts: ConfigToJSONSchemaOptions = {}, ): JSONSchema4 { - // a mutable Map to store custom top-level `interfaceName` types. Fields with an `interfaceName` property will be moved to the top-level definitions here + // a mutable Map of top-level definitions in the generated JSON Schema. + // - `array`/`group`/named-`tab` fields are registered here when they set + // `interfaceName` (otherwise they stay inline). + // - `block` configs always register here, keyed by `block.interfaceName` + // if set, otherwise a PascalCase form of the slug via `toWords`. const interfaceNameDefinitions: Map = new Map() // Used for relationship fields, to determine whether to use a string or number type for the ID. @@ -1377,7 +1385,9 @@ export function configToJSONSchema( required: ['blockType', ...blockFieldSchemas.required], } - const interfaceName = block.interfaceName ?? block.slug + // `block.interfaceName` is treated as an override of the auto-derived + // PascalCase name. Without it, blocks still get a top-level interface. + const interfaceName = block.interfaceName ?? toWords(block.slug, true) interfaceNameDefinitions.set(interfaceName, blockSchema) blocksDefinition.properties![block.slug] = { From b43d07514b634ac9ac4976a453ea574f462fc16c Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 28 May 2026 21:49:13 +0000 Subject: [PATCH 2/6] cleanup --- packages/payload/src/fields/config/types.ts | 9 +--- .../src/utilities/configToJSONSchema.spec.ts | 44 +------------------ .../src/utilities/configToJSONSchema.ts | 5 --- 3 files changed, 4 insertions(+), 54 deletions(-) diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index e14a6e34e66..7ea57179810 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1525,14 +1525,9 @@ export type Block = { /** * Override the name of the top-level TypeScript interface and GraphQL * type generated for this block. Blocks **always** generate a top-level - * interface — by default it's a PascalCase form of the slug + * interface - by default it's a PascalCase form of the slug * (`'content-block'` → `ContentBlock`). Set this to take control of the - * generated name (useful for disambiguating slug-PascalCase collisions - * or referencing the type elsewhere under a name of your choosing). - * - * **Note**: Top-level types share a namespace with collections, arrays, - * groups, tabs, and other blocks — set an explicit `interfaceName` to - * resolve any collisions. + * generated name */ interfaceName?: string jsx?: BlockJSX diff --git a/packages/payload/src/utilities/configToJSONSchema.spec.ts b/packages/payload/src/utilities/configToJSONSchema.spec.ts index 582a24e0b74..124dc752abb 100644 --- a/packages/payload/src/utilities/configToJSONSchema.spec.ts +++ b/packages/payload/src/utilities/configToJSONSchema.spec.ts @@ -140,53 +140,13 @@ describe('configToJSONSchema', () => { blockFieldWithFields: { type: ['array', 'null'], items: { - oneOf: [ - { - type: 'object', - additionalProperties: false, - properties: { - id: { - type: ['string', 'null'], - }, - blockName: { - type: ['string', 'null'], - }, - blockType: { - const: 'test', - }, - field: { - type: ['string', 'null'], - }, - }, - required: ['blockType'], - }, - ], + oneOf: [{ $ref: '#/definitions/Test' }], }, }, blockFieldWithFieldsRequired: { type: ['array', 'null'], items: { - oneOf: [ - { - type: 'object', - additionalProperties: false, - properties: { - id: { - type: ['string', 'null'], - }, - blockName: { - type: ['string', 'null'], - }, - blockType: { - const: 'test', - }, - field: { - type: 'string', - }, - }, - required: ['blockType', 'field'], - }, - ], + oneOf: [{ $ref: '#/definitions/Test' }], }, }, }, diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 100fd663bd0..b73094d4bd1 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -465,9 +465,6 @@ export function fieldsToJSONSchema( } if (!opts.forceInlineBlocks) { - // Always register the block as a top-level definition, - // using the user's `interfaceName` override if set or a - // PascalCase fallback derived from the slug. const interfaceName = block.interfaceName ?? toWords(block.slug, true) interfaceNameDefinitions.set(interfaceName, blockSchema) @@ -1385,8 +1382,6 @@ export function configToJSONSchema( required: ['blockType', ...blockFieldSchemas.required], } - // `block.interfaceName` is treated as an override of the auto-derived - // PascalCase name. Without it, blocks still get a top-level interface. const interfaceName = block.interfaceName ?? toWords(block.slug, true) interfaceNameDefinitions.set(interfaceName, blockSchema) From ff1d5bbea3aaaf2252d8310cd72bfe09251af657 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Fri, 29 May 2026 06:19:00 +0000 Subject: [PATCH 3/6] minor docs improvement --- docs/fields/array.mdx | 2 +- docs/fields/blocks.mdx | 2 +- docs/fields/checkbox.mdx | 2 +- docs/fields/code.mdx | 2 +- docs/fields/date.mdx | 2 +- docs/fields/email.mdx | 2 +- docs/fields/group.mdx | 2 +- docs/fields/join.mdx | 2 +- docs/fields/json.mdx | 2 +- docs/fields/number.mdx | 2 +- docs/fields/point.mdx | 2 +- docs/fields/radio.mdx | 2 +- docs/fields/relationship.mdx | 2 +- docs/fields/rich-text.mdx | 2 +- docs/fields/select.mdx | 2 +- docs/fields/text.mdx | 2 +- docs/fields/textarea.mdx | 2 +- docs/fields/upload.mdx | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index 255e55da36b..4960330d553 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -59,7 +59,7 @@ export const MyArrayField: Field = { | **`custom`** | Extension point for adding custom data (e.g. for plugins) | | **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). | | **`dbName`** | Custom table name for the field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/overview#string-path-virtual-fields). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index e6800781ded..a855556c6ed 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -60,7 +60,7 @@ This page is divided into two parts: first, the settings of the Blocks Field, an | **`labels`** | Customize the block row labels appearing in the Admin dashboard. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/overview#string-path-virtual-fields). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/checkbox.mdx b/docs/fields/checkbox.mdx index b56a37534f8..fd140dacc38 100644 --- a/docs/fields/checkbox.mdx +++ b/docs/fields/checkbox.mdx @@ -43,7 +43,7 @@ export const MyCheckboxField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](./overview#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index 5cc6e495e1b..325de4aeffa 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -47,7 +47,7 @@ export const MyBlocksField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index feb7b1e5920..388b7c158af 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -44,7 +44,7 @@ export const MyDateField: Field = { | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | | **`timezone`** \* | Set to `true` to enable timezone selection on this field. [More details](#timezones). | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index 41e47b6a4aa..cabaecde58a 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -44,7 +44,7 @@ export const MyEmailField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 81f3fcfbc1f..7dadfa15eb5 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -48,7 +48,7 @@ export const MyGroupField: Field = { | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | | **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx index 584d1269ab3..16b048c8205 100644 --- a/docs/fields/join.mdx +++ b/docs/fields/join.mdx @@ -148,7 +148,7 @@ powerful Admin UI. | **`defaultSort`** | The field name used to specify the order the joined documents are returned. | | **`admin`** | Admin-specific configuration. [More details](#admin-config-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins). | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation. | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation. | | **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/json.mdx b/docs/fields/json.mdx index 3284fc8b1e8..6d4cba3692f 100644 --- a/docs/fields/json.mdx +++ b/docs/fields/json.mdx @@ -46,7 +46,7 @@ export const MyJSONField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 1b1401c7fd7..3eeb37af7b5 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -49,7 +49,7 @@ export const MyNumberField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx index 011d07d9c36..dd5d1746964 100644 --- a/docs/fields/point.mdx +++ b/docs/fields/point.mdx @@ -48,7 +48,7 @@ export const MyPointField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](./overview#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index b4c96fb6d83..fb3c3388c26 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -51,7 +51,7 @@ export const MyRadioField: Field = { | **`custom`** | Extension point for adding custom data (e.g. for plugins) | | **`enumName`** | Custom enum name for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. | | **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 028a3e46106..99ff6c2197c 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -59,7 +59,7 @@ export const MyRelationshipField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to link the field with a relationship. See [Virtual Field Configuration](/docs/fields/overview#virtual-field-configuration) | | **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) | diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index 4b9937e81d7..1a61cd8568d 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -34,7 +34,7 @@ Instead, you can invest your time and effort into learning the underlying open-s | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`editor`** | Customize or override the rich text editor. [More details](../rich-text/overview). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | \*_ An asterisk denotes that a property is required._ diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index 9220be9f56c..7adacda2e3a 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -55,7 +55,7 @@ export const MySelectField: Field = { | **`dbName`** | Custom table name (if `hasMany` set to `true`) for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. | | **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). | | **`filterOptions`** | Dynamically filter which options are available based on the user, data, etc. [More details](#filteroptions) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index 98af0369d7e..6a7f052d0a9 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -49,7 +49,7 @@ export const MyTextField: Field = { | **`hasMany`** | Makes this field an ordered array of text instead of just a single text. | | **`minRows`** | Minimum number of texts in the array, if `hasMany` is set to true. | | **`maxRows`** | Maximum number of texts in the array, if `hasMany` is set to true. | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 62367c174fc..bde28b433ec 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -46,7 +46,7 @@ export const MyTextareaField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [More details](#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | _\* An asterisk denotes that a property is required._ diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 13bdc7c18f6..e5331f2c7f2 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -67,7 +67,7 @@ export const MyUploadField: Field = { | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. [Admin Options](./overview#admin-options). | | **`custom`** | Extension point for adding custom data (e.g. for plugins) | -| **`jsonSchema`** | Override field json schema used for type generation and mcp validation | +| **`jsonSchema`** | Override field JSON Schema used for type generation and MCP validation | | **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) | | **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) | From 5c6fd72859a8a039fe32e2595a40e0af9f100efd Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Fri, 29 May 2026 18:29:07 +0000 Subject: [PATCH 4/6] feat: hash-disambiguate colliding auto-generated block interface names --- docs/migration-guide/v4.mdx | 2 +- docs/typescript/generating-types.mdx | 17 ++ .../src/utilities/configToJSONSchema.spec.ts | 66 ++++++- .../src/utilities/configToJSONSchema.ts | 162 +++++++++++++++++- 4 files changed, 241 insertions(+), 6 deletions(-) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index bd63109ca8a..07bc9a2c912 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -389,7 +389,7 @@ Before, a block's fields only became a top-level interface in `payload-types.ts` Now **every block always emits a top-level interface**. The name comes from the slug, converted to PascalCase (`'content-block'` → `ContentBlock`, `'richTextBlock'` → `RichTextBlock`). `interfaceName` still works, but it's now an _override_ for that default name rather than the switch that enables generation. -If a block's auto-derived name collides with another type in your generated file (a collection, an array's `interfaceName`, etc.), set `interfaceName` on the block to disambiguate. +When two **different** blocks resolve to the same auto-derived name (same slug, different fields — e.g. a shared block overridden in one collection), Payload appends a stable content hash to each colliding name (`Hero_3F2A1B0C`) so the types stay correct and don't silently overwrite one another. Set an explicit `interfaceName` on the block to choose a clean, stable name instead. See [Block interface name collisions](/docs/typescript/generating-types#block-interface-name-collisions). ### RichText adapter `outputSchema` renamed to `jsonSchema` diff --git a/docs/typescript/generating-types.mdx b/docs/typescript/generating-types.mdx index ded2d9c3e8a..c6c1ea0bce8 100644 --- a/docs/typescript/generating-types.mdx +++ b/docs/typescript/generating-types.mdx @@ -234,6 +234,23 @@ appending the field type to the end, i.e. `MetaGroup` or similar. +### Block interface name collisions + +Every `block` generates a top-level interface named after its slug (`'hero'` → `Hero`). If two **different** blocks resolve to the same name but have different fields — for example a `hero` block reused across collections but overridden in one of them — a single shared `Hero` interface couldn't represent both shapes. To keep the generated types correct and stable, Payload appends a short content hash to each colliding block's name: + +```ts +export interface Hero_3F2A1B0C { + /* one shape */ +} +export interface Hero_9C7E4012 { + /* the other shape */ +} +``` + +The hash is derived from the block's fields, so the **same schema always produces the same name** across regenerations — it only changes when the block's fields change. If you import a hashed interface and later change that block, you'll get a type error that points you at the change instead of silently receiving the wrong shape. + +To choose the name yourself and avoid the hash, set an explicit [`interfaceName`](/docs/fields/blocks) on the block — explicit names are always used verbatim. This is the recommended fix when two blocks intentionally share a slug, or when you want a stable, human-readable name to import. + ## Using your types Now that your types have been generated, Payload's Local API will now be typed. It is common for users to want to use this in their frontend code, we recommend generating them with Payload and then copying the file over to your frontend codebase. This is the simplest way to get your types into your frontend codebase. diff --git a/packages/payload/src/utilities/configToJSONSchema.spec.ts b/packages/payload/src/utilities/configToJSONSchema.spec.ts index 3c3ea02b8ae..5e13fdcda70 100644 --- a/packages/payload/src/utilities/configToJSONSchema.spec.ts +++ b/packages/payload/src/utilities/configToJSONSchema.spec.ts @@ -102,7 +102,7 @@ describe('configToJSONSchema', () => { type: 'blocks', blocks: [ { - slug: 'test', + slug: 'testRequired', fields: [ { name: 'field', @@ -146,7 +146,7 @@ describe('configToJSONSchema', () => { blockFieldWithFieldsRequired: { type: ['array', 'null'], items: { - oneOf: [{ $ref: '#/definitions/Test' }], + oneOf: [{ $ref: '#/definitions/TestRequired' }], }, }, }, @@ -155,6 +155,68 @@ describe('configToJSONSchema', () => { }) }) + it('disambiguates colliding block interface names with a stable content hash', async () => { + // @ts-expect-error - partial config for testing + const config: Config = { + collections: [ + { + slug: 'pages', + fields: [ + { + name: 'layout', + type: 'blocks', + blocks: [ + { slug: 'hero', fields: [{ name: 'title', type: 'text' }] }, + { slug: 'cta', fields: [{ name: 'label', type: 'text' }] }, + ], + }, + ], + timestamps: false, + }, + { + slug: 'posts', + fields: [ + { + name: 'layout', + type: 'blocks', + // Same slug `hero`, DIFFERENT fields → name collision. + blocks: [{ slug: 'hero', fields: [{ name: 'heading', type: 'text' }] }], + }, + ], + timestamps: false, + }, + ], + } + + const sanitizedConfig = await sanitizeConfig(config) + const schema = configToJSONSchema(sanitizedConfig, 'text') + const defs = schema.definitions! + + // Unique block keeps its clean name; no bare `Hero` exists (both collided). + expect(defs.Cta).toBeDefined() + expect(defs.Hero).toBeUndefined() + + // Both colliding `hero` blocks are disambiguated with distinct content hashes. + const heroNames = Object.keys(defs).filter((k) => /^Hero_[0-9A-F]{8}$/.test(k)) + expect(heroNames).toHaveLength(2) + expect(heroNames[0]).not.toBe(heroNames[1]) + + // The disambiguated interface carries the explanatory JSDoc note. + expect((defs[heroNames[0]!] as { description?: string }).description).toContain('content hash') + + // Block fields reference the hashed name, never a bare `Hero`. + const pagesLayout = (defs.pages as { properties: { layout: { items: { oneOf: Array<{ $ref: string }> } } } }) + .properties.layout.items.oneOf + expect(pagesLayout.some((r) => /^#\/definitions\/Hero_[0-9A-F]{8}$/.test(r.$ref))).toBe(true) + expect(pagesLayout.some((r) => r.$ref === '#/definitions/Cta')).toBe(true) + + // Hashing is deterministic: regenerating yields identical names. + const schema2 = configToJSONSchema(sanitizedConfig, 'text') + expect(Object.keys(schema2.definitions!).filter((k) => /^Hero_/.test(k)).sort()).toStrictEqual( + heroNames.sort(), + ) + }) + it('should handle tabs and named tabs with required fields', async () => { // @ts-expect-error const config: Config = { diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index b6ab7d1f698..5752b10b875 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -1,6 +1,8 @@ import type { I18n } from '@payloadcms/translations' import type { JSONSchema4, JSONSchema4TypeName } from 'json-schema' +import { createHash } from 'crypto' + import type { Auth } from '../auth/types.js' import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { SanitizedConfig } from '../config/types.js' @@ -347,6 +349,12 @@ function entityOrFieldToJsDocs({ } type ConfigToJSONSchemaOptions = { + /** + * @internal Resolves a block's top-level interface name, applying a content-hash + * suffix when its auto-derived name collides with a differently-shaped block. + * Set internally by `configToJSONSchema`; falls back to the plain base name. + */ + blockInterfaceNameResolver?: (block: BlockForNaming) => string forceInlineBlocks?: boolean } @@ -437,7 +445,7 @@ export function fieldsToJSONSchema( if (!opts.forceInlineBlocks) { return { - $ref: `#/definitions/${resolvedBlock.interfaceName ?? toWords(resolvedBlock.slug, true)}`, + $ref: `#/definitions/${opts.blockInterfaceNameResolver?.(resolvedBlock) ?? blockBaseInterfaceName(resolvedBlock)}`, } } @@ -465,7 +473,12 @@ export function fieldsToJSONSchema( } if (!opts.forceInlineBlocks) { - const interfaceName = block.interfaceName ?? toWords(block.slug, true) + const baseName = blockBaseInterfaceName(block) + const interfaceName = + opts.blockInterfaceNameResolver?.(block) ?? baseName + if (interfaceName !== baseName) { + blockSchema.description = blockInterfaceCollisionNote(baseName) + } interfaceNameDefinitions.set(interfaceName, blockSchema) return { @@ -1254,6 +1267,132 @@ function generateAuthOperationSchemas(collections: SanitizedCollectionConfig[]): } } +type BlockForNaming = { flattenedFields: FlattenedField[]; interfaceName?: string; slug: string } + +/** Auto-derived top-level interface name for a block: its `interfaceName` override, else PascalCase of the slug. */ +const blockBaseInterfaceName = (block: { interfaceName?: string; slug: string }): string => + block.interfaceName ?? toWords(block.slug, true) + +/** Builds the `{ type: 'object', ... }` JSON schema for a single block's value. */ +const buildBlockSchema = ( + block: BlockForNaming, + collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, + interfaceNameDefinitions: Map, + config: SanitizedConfig | undefined, + i18n: I18n | undefined, + opts: ConfigToJSONSchemaOptions, +): JSONSchema4 => { + const blockFieldSchemas = fieldsToJSONSchema( + collectionIDFieldTypes, + block.flattenedFields, + interfaceNameDefinitions, + config, + i18n, + opts, + ) + return { + type: 'object', + additionalProperties: false, + properties: { ...blockFieldSchemas.properties, blockType: { const: block.slug } }, + required: ['blockType', ...blockFieldSchemas.required], + } +} + +/** JSDoc note attached to hash-disambiguated block interfaces in the generated types. */ +export const blockInterfaceCollisionNote = (baseName: string): string => + `Multiple blocks resolve to the \`${baseName}\` interface with different fields, so a content hash is appended to keep the generated types stable and unambiguous. Set a unique \`interfaceName\` on the block to choose the name yourself. See https://payloadcms.com/docs/typescript/generating-types#block-interface-name-collisions` + +/** + * Blocks always generate a top-level interface named after their slug. When two + * blocks resolve to the same auto-derived name but have *different* fields, a + * clean shared name would silently mistype one of them. This walks every block + * in the config, groups auto-derived names by a content hash of each block's + * schema, and returns a resolver that: + * + * - keeps the clean `MyBlock` name when the name maps to a single schema, + * - appends a stable content hash (`MyBlock_3F2A`) to every block sharing a + * colliding name (same schema → same hash, so it's stable across builds), + * - always honors an explicit `interfaceName` verbatim. + */ +const buildBlockInterfaceNameResolver = ( + config: SanitizedConfig, + collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, + i18n: I18n | undefined, + opts: ConfigToJSONSchemaOptions, +): ((block: BlockForNaming) => string) => { + const autoNameToHashes = new Map>() + const blockToHash = new Map() + const visited = new Set() + + const hashBlock = (block: BlockForNaming): string => { + const cached = blockToHash.get(block) + if (cached) { + return cached + } + const schema = buildBlockSchema(block, collectionIDFieldTypes, new Map(), config, i18n, opts) + const hash = createHash('sha256').update(JSON.stringify(schema)).digest('hex').slice(0, 8).toUpperCase() + blockToHash.set(block, hash) + return hash + } + + const walkBlocks = (fields: FlattenedField[] | undefined): void => { + for (const field of fields ?? []) { + if (field.type === 'blocks') { + for (const ref of field.blockReferences ?? field.blocks) { + const block = typeof ref === 'string' ? config.blocks?.find((b) => b.slug === ref) : ref + if (block) { + visit(block) + } + } + } else if ('flattenedFields' in field && field.flattenedFields) { + walkBlocks(field.flattenedFields) + } + } + } + + const visit = (block: BlockForNaming): void => { + if (visited.has(block)) { + return + } + visited.add(block) + if (!block.interfaceName) { + const name = toWords(block.slug, true) + let hashes = autoNameToHashes.get(name) + if (!hashes) { + hashes = new Set() + autoNameToHashes.set(name, hashes) + } + hashes.add(hashBlock(block)) + } + walkBlocks(block.flattenedFields) + } + + for (const collection of config.collections) { + walkBlocks(collection.flattenedFields) + } + for (const global of config.globals) { + walkBlocks(global.flattenedFields) + } + for (const block of config.blocks ?? []) { + visit(block) + } + + const collidingAutoNames = new Set() + for (const [name, hashes] of autoNameToHashes) { + if (hashes.size > 1) { + collidingAutoNames.add(name) + } + } + + return (block: BlockForNaming): string => { + if (block.interfaceName) { + return block.interfaceName + } + const name = toWords(block.slug, true) + return collidingAutoNames.has(name) ? `${name}_${hashBlock(block)}` : name + } +} + /** * This is used for generating the TypeScript types (payload-types.ts) with the payload generate:types command. */ @@ -1276,6 +1415,19 @@ export function configToJSONSchema( defaultIDType: defaultIDType!, }) + // Pre-pass: resolve collision-safe block interface names before any block is + // registered, so every site (inline blocks, block references, config.blocks) + // agrees on the same name. Threaded through `opts`. + opts = { + ...opts, + blockInterfaceNameResolver: buildBlockInterfaceNameResolver( + config, + collectionIDFieldTypes, + i18n, + opts, + ), + } + // Collections and Globals have to be moved to the top-level definitions as well. Reason: The top-level type will be the `Config` type - we don't want all collection and global // types to be inlined inside the `Config` type @@ -1382,7 +1534,11 @@ export function configToJSONSchema( required: ['blockType', ...blockFieldSchemas.required], } - const interfaceName = block.interfaceName ?? toWords(block.slug, true) + const baseName = blockBaseInterfaceName(block) + const interfaceName = opts.blockInterfaceNameResolver?.(block) ?? baseName + if (interfaceName !== baseName) { + blockSchema.description = blockInterfaceCollisionNote(baseName) + } interfaceNameDefinitions.set(interfaceName, blockSchema) blocksDefinition.properties![block.slug] = { From 67802d9d8c3425a14f7417956565ad779289b594 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Fri, 29 May 2026 19:46:21 +0000 Subject: [PATCH 5/6] refactor: resolve block interface names via a plain memoized function, not opts --- .../src/utilities/configToJSONSchema.ts | 166 ++++++++---------- 1 file changed, 75 insertions(+), 91 deletions(-) diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 5752b10b875..1793aa12620 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -349,12 +349,6 @@ function entityOrFieldToJsDocs({ } type ConfigToJSONSchemaOptions = { - /** - * @internal Resolves a block's top-level interface name, applying a content-hash - * suffix when its auto-derived name collides with a differently-shaped block. - * Set internally by `configToJSONSchema`; falls back to the plain base name. - */ - blockInterfaceNameResolver?: (block: BlockForNaming) => string forceInlineBlocks?: boolean } @@ -445,7 +439,7 @@ export function fieldsToJSONSchema( if (!opts.forceInlineBlocks) { return { - $ref: `#/definitions/${opts.blockInterfaceNameResolver?.(resolvedBlock) ?? blockBaseInterfaceName(resolvedBlock)}`, + $ref: `#/definitions/${resolveBlockInterfaceName(resolvedBlock, config, collectionIDFieldTypes, i18n)}`, } } @@ -474,8 +468,12 @@ export function fieldsToJSONSchema( if (!opts.forceInlineBlocks) { const baseName = blockBaseInterfaceName(block) - const interfaceName = - opts.blockInterfaceNameResolver?.(block) ?? baseName + const interfaceName = resolveBlockInterfaceName( + block, + config, + collectionIDFieldTypes, + i18n, + ) if (interfaceName !== baseName) { blockSchema.description = blockInterfaceCollisionNote(baseName) } @@ -1273,28 +1271,25 @@ type BlockForNaming = { flattenedFields: FlattenedField[]; interfaceName?: strin const blockBaseInterfaceName = (block: { interfaceName?: string; slug: string }): string => block.interfaceName ?? toWords(block.slug, true) -/** Builds the `{ type: 'object', ... }` JSON schema for a single block's value. */ +/** Builds the `{ type: 'object', ... }` value schema for one block, used to fingerprint its shape. */ const buildBlockSchema = ( block: BlockForNaming, collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, - interfaceNameDefinitions: Map, config: SanitizedConfig | undefined, i18n: I18n | undefined, - opts: ConfigToJSONSchemaOptions, ): JSONSchema4 => { - const blockFieldSchemas = fieldsToJSONSchema( + const fields = fieldsToJSONSchema( collectionIDFieldTypes, block.flattenedFields, - interfaceNameDefinitions, + new Map(), config, i18n, - opts, ) return { type: 'object', additionalProperties: false, - properties: { ...blockFieldSchemas.properties, blockType: { const: block.slug } }, - required: ['blockType', ...blockFieldSchemas.required], + properties: { ...fields.properties, blockType: { const: block.slug } }, + required: ['blockType', ...fields.required], } } @@ -1302,97 +1297,99 @@ const buildBlockSchema = ( export const blockInterfaceCollisionNote = (baseName: string): string => `Multiple blocks resolve to the \`${baseName}\` interface with different fields, so a content hash is appended to keep the generated types stable and unambiguous. Set a unique \`interfaceName\` on the block to choose the name yourself. See https://payloadcms.com/docs/typescript/generating-types#block-interface-name-collisions` +const blockInterfaceNamesCache = new WeakMap>() + /** - * Blocks always generate a top-level interface named after their slug. When two - * blocks resolve to the same auto-derived name but have *different* fields, a - * clean shared name would silently mistype one of them. This walks every block - * in the config, groups auto-derived names by a content hash of each block's - * schema, and returns a resolver that: + * Final top-level interface name for every block in the config. * - * - keeps the clean `MyBlock` name when the name maps to a single schema, - * - appends a stable content hash (`MyBlock_3F2A`) to every block sharing a - * colliding name (same schema → same hash, so it's stable across builds), - * - always honors an explicit `interfaceName` verbatim. + * Blocks always generate a top-level interface named after their slug. When two + * blocks share that auto-derived name but have *different* fields, a clean + * shared name would silently mistype one of them — so every colliding block + * gets a stable content-hash suffix (`MyBlock_3F2A`). Unique names stay clean, + * and an explicit `interfaceName` is always used verbatim. Memoized: the + * whole-config walk runs once per config. */ -const buildBlockInterfaceNameResolver = ( +const getBlockInterfaceNames = ( config: SanitizedConfig, collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, i18n: I18n | undefined, - opts: ConfigToJSONSchemaOptions, -): ((block: BlockForNaming) => string) => { - const autoNameToHashes = new Map>() - const blockToHash = new Map() - const visited = new Set() - - const hashBlock = (block: BlockForNaming): string => { - const cached = blockToHash.get(block) - if (cached) { - return cached - } - const schema = buildBlockSchema(block, collectionIDFieldTypes, new Map(), config, i18n, opts) - const hash = createHash('sha256').update(JSON.stringify(schema)).digest('hex').slice(0, 8).toUpperCase() - blockToHash.set(block, hash) - return hash +): Map => { + const cached = blockInterfaceNamesCache.get(config) + if (cached) { + return cached } - const walkBlocks = (fields: FlattenedField[] | undefined): void => { + // 1. Collect every block once (collections, globals, config.blocks + nested). + const blocks = new Set() + const collect = (fields: FlattenedField[] | undefined): void => { for (const field of fields ?? []) { if (field.type === 'blocks') { for (const ref of field.blockReferences ?? field.blocks) { const block = typeof ref === 'string' ? config.blocks?.find((b) => b.slug === ref) : ref - if (block) { - visit(block) + if (block && !blocks.has(block)) { + blocks.add(block) + collect(block.flattenedFields) } } } else if ('flattenedFields' in field && field.flattenedFields) { - walkBlocks(field.flattenedFields) - } - } - } - - const visit = (block: BlockForNaming): void => { - if (visited.has(block)) { - return - } - visited.add(block) - if (!block.interfaceName) { - const name = toWords(block.slug, true) - let hashes = autoNameToHashes.get(name) - if (!hashes) { - hashes = new Set() - autoNameToHashes.set(name, hashes) + collect(field.flattenedFields) } - hashes.add(hashBlock(block)) } - walkBlocks(block.flattenedFields) } - for (const collection of config.collections) { - walkBlocks(collection.flattenedFields) + collect(collection.flattenedFields) } for (const global of config.globals) { - walkBlocks(global.flattenedFields) + collect(global.flattenedFields) } for (const block of config.blocks ?? []) { - visit(block) + if (!blocks.has(block)) { + blocks.add(block) + collect(block.flattenedFields) + } } - const collidingAutoNames = new Set() - for (const [name, hashes] of autoNameToHashes) { - if (hashes.size > 1) { - collidingAutoNames.add(name) + // 2. Hash each block's shape; group auto-derived names by the shapes they map to. + const hashByBlock = new Map() + const hashesByAutoName = new Map>() + for (const block of blocks) { + const hash = createHash('sha256') + .update(JSON.stringify(buildBlockSchema(block, collectionIDFieldTypes, config, i18n))) + .digest('hex') + .slice(0, 8) + .toUpperCase() + hashByBlock.set(block, hash) + if (!block.interfaceName) { + const name = toWords(block.slug, true) + if (!hashesByAutoName.has(name)) { + hashesByAutoName.set(name, new Set()) + } + hashesByAutoName.get(name)!.add(hash) } } - return (block: BlockForNaming): string => { - if (block.interfaceName) { - return block.interfaceName - } - const name = toWords(block.slug, true) - return collidingAutoNames.has(name) ? `${name}_${hashBlock(block)}` : name + // 3. A name mapping to >1 distinct shape collides → suffix every sharer with its hash. + const names = new Map() + for (const block of blocks) { + const base = blockBaseInterfaceName(block) + const collides = !block.interfaceName && (hashesByAutoName.get(base)?.size ?? 0) > 1 + names.set(block, collides ? `${base}_${hashByBlock.get(block)!}` : base) } + + blockInterfaceNamesCache.set(config, names) + return names } +/** Final, collision-safe interface name for a single block. See {@link getBlockInterfaceNames}. */ +const resolveBlockInterfaceName = ( + block: BlockForNaming, + config: SanitizedConfig | undefined, + collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, + i18n: I18n | undefined, +): string => + (config && getBlockInterfaceNames(config, collectionIDFieldTypes, i18n).get(block)) ?? + blockBaseInterfaceName(block) + /** * This is used for generating the TypeScript types (payload-types.ts) with the payload generate:types command. */ @@ -1415,19 +1412,6 @@ export function configToJSONSchema( defaultIDType: defaultIDType!, }) - // Pre-pass: resolve collision-safe block interface names before any block is - // registered, so every site (inline blocks, block references, config.blocks) - // agrees on the same name. Threaded through `opts`. - opts = { - ...opts, - blockInterfaceNameResolver: buildBlockInterfaceNameResolver( - config, - collectionIDFieldTypes, - i18n, - opts, - ), - } - // Collections and Globals have to be moved to the top-level definitions as well. Reason: The top-level type will be the `Config` type - we don't want all collection and global // types to be inlined inside the `Config` type @@ -1535,7 +1519,7 @@ export function configToJSONSchema( } const baseName = blockBaseInterfaceName(block) - const interfaceName = opts.blockInterfaceNameResolver?.(block) ?? baseName + const interfaceName = resolveBlockInterfaceName(block, config, collectionIDFieldTypes, i18n) if (interfaceName !== baseName) { blockSchema.description = blockInterfaceCollisionNote(baseName) } From 5d4fdc0eead493eb4af274cd062f9f7c9ebee2be Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 2 Jun 2026 02:15:57 +0000 Subject: [PATCH 6/6] simplify --- docs/migration-guide/v4.mdx | 2 +- docs/typescript/generating-types.mdx | 10 +- .../src/utilities/configToJSONSchema.spec.ts | 45 ++-- .../src/utilities/configToJSONSchema.ts | 202 ++++-------------- 4 files changed, 79 insertions(+), 180 deletions(-) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index d8bba111308..e06f5abfdda 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -427,7 +427,7 @@ Before, a block's fields only became a top-level interface in `payload-types.ts` Now **every block always emits a top-level interface**. The name comes from the slug, converted to PascalCase (`'content-block'` → `ContentBlock`, `'richTextBlock'` → `RichTextBlock`). `interfaceName` still works, but it's now an _override_ for that default name rather than the switch that enables generation. -When two **different** blocks resolve to the same auto-derived name (same slug, different fields — e.g. a shared block overridden in one collection), Payload appends a stable content hash to each colliding name (`Hero_3F2A1B0C`) so the types stay correct and don't silently overwrite one another. Set an explicit `interfaceName` on the block to choose a clean, stable name instead. See [Block interface name collisions](/docs/typescript/generating-types#block-interface-name-collisions). +When two **different** blocks resolve to the same auto-derived name (same slug, different fields — e.g. a shared block overridden in one collection), the first keeps the clean name and any later block with a different shape gets a stable content-hash suffix (`Hero_3F2A1B0C`), so the types stay correct and don't silently overwrite one another. Set an explicit `interfaceName` on the block to choose a clean, stable name instead. See [Block interface name collisions](/docs/typescript/generating-types#block-interface-name-collisions). ### RichText adapter `outputSchema` renamed to `jsonSchema` diff --git a/docs/typescript/generating-types.mdx b/docs/typescript/generating-types.mdx index c6c1ea0bce8..48c698b3891 100644 --- a/docs/typescript/generating-types.mdx +++ b/docs/typescript/generating-types.mdx @@ -236,18 +236,18 @@ appending the field type to the end, i.e. `MetaGroup` or similar. ### Block interface name collisions -Every `block` generates a top-level interface named after its slug (`'hero'` → `Hero`). If two **different** blocks resolve to the same name but have different fields — for example a `hero` block reused across collections but overridden in one of them — a single shared `Hero` interface couldn't represent both shapes. To keep the generated types correct and stable, Payload appends a short content hash to each colliding block's name: +Every `block` generates a top-level interface named after its slug (`'hero'` → `Hero`). If two **different** blocks resolve to the same name but have different fields — for example a `hero` block reused across collections but overridden in one of them — a single shared `Hero` interface couldn't represent both shapes. The first block keeps the clean name; any later block with a different shape gets a short content-hash suffix, so neither is silently mistyped: ```ts -export interface Hero_3F2A1B0C { - /* one shape */ +export interface Hero { + /* the first shape */ } export interface Hero_9C7E4012 { - /* the other shape */ + /* the differing shape */ } ``` -The hash is derived from the block's fields, so the **same schema always produces the same name** across regenerations — it only changes when the block's fields change. If you import a hashed interface and later change that block, you'll get a type error that points you at the change instead of silently receiving the wrong shape. +The suffix is derived from the block's fields, so the **same schema always produces the same name** across regenerations — it only changes when the block's fields change. If you import a hashed interface and later change that block, you'll get a type error that points you at the change instead of silently receiving the wrong shape. To choose the name yourself and avoid the hash, set an explicit [`interfaceName`](/docs/fields/blocks) on the block — explicit names are always used verbatim. This is the recommended fix when two blocks intentionally share a slug, or when you want a stable, human-readable name to import. diff --git a/packages/payload/src/utilities/configToJSONSchema.spec.ts b/packages/payload/src/utilities/configToJSONSchema.spec.ts index 5e13fdcda70..bb96a808c1b 100644 --- a/packages/payload/src/utilities/configToJSONSchema.spec.ts +++ b/packages/payload/src/utilities/configToJSONSchema.spec.ts @@ -155,7 +155,7 @@ describe('configToJSONSchema', () => { }) }) - it('disambiguates colliding block interface names with a stable content hash', async () => { + it('keeps the first block interface name clean and content-hashes the colliding one', async () => { // @ts-expect-error - partial config for testing const config: Config = { collections: [ @@ -179,7 +179,7 @@ describe('configToJSONSchema', () => { { name: 'layout', type: 'blocks', - // Same slug `hero`, DIFFERENT fields → name collision. + // Same slug `hero`, DIFFERENT fields → name collision with pages' hero. blocks: [{ slug: 'hero', fields: [{ name: 'heading', type: 'text' }] }], }, ], @@ -192,28 +192,39 @@ describe('configToJSONSchema', () => { const schema = configToJSONSchema(sanitizedConfig, 'text') const defs = schema.definitions! - // Unique block keeps its clean name; no bare `Hero` exists (both collided). + // The first `hero` keeps the clean name; the unique block is unaffected. + expect(defs.Hero).toBeDefined() expect(defs.Cta).toBeDefined() - expect(defs.Hero).toBeUndefined() - // Both colliding `hero` blocks are disambiguated with distinct content hashes. - const heroNames = Object.keys(defs).filter((k) => /^Hero_[0-9A-F]{8}$/.test(k)) - expect(heroNames).toHaveLength(2) - expect(heroNames[0]).not.toBe(heroNames[1]) + // Only the second, differently-shaped `hero` is disambiguated with a content hash. + const hashedHeroNames = Object.keys(defs).filter((k) => /^Hero_[0-9A-F]{8}$/.test(k)) + expect(hashedHeroNames).toHaveLength(1) - // The disambiguated interface carries the explanatory JSDoc note. - expect((defs[heroNames[0]!] as { description?: string }).description).toContain('content hash') + // The disambiguated interface carries the explanatory JSDoc note; the clean one does not. + expect((defs[hashedHeroNames[0]!] as { description?: string }).description).toContain( + 'content hash', + ) + expect((defs.Hero as { description?: string }).description).toBeUndefined() - // Block fields reference the hashed name, never a bare `Hero`. - const pagesLayout = (defs.pages as { properties: { layout: { items: { oneOf: Array<{ $ref: string }> } } } }) - .properties.layout.items.oneOf - expect(pagesLayout.some((r) => /^#\/definitions\/Hero_[0-9A-F]{8}$/.test(r.$ref))).toBe(true) - expect(pagesLayout.some((r) => r.$ref === '#/definitions/Cta')).toBe(true) + // Each collection's block field references its own block's interface. + const refsOf = (slug: string): string[] => + ( + defs[slug] as { properties: { layout: { items: { oneOf: Array<{ $ref: string }> } } } } + ).properties.layout.items.oneOf.map((r) => r.$ref) + expect(refsOf('pages')).toContain('#/definitions/Hero') + expect(refsOf('pages')).toContain('#/definitions/Cta') + expect(refsOf('posts')).toStrictEqual([`#/definitions/${hashedHeroNames[0]}`]) // Hashing is deterministic: regenerating yields identical names. const schema2 = configToJSONSchema(sanitizedConfig, 'text') - expect(Object.keys(schema2.definitions!).filter((k) => /^Hero_/.test(k)).sort()).toStrictEqual( - heroNames.sort(), + expect( + Object.keys(schema2.definitions!) + .filter((k) => /^Hero/.test(k)) + .sort(), + ).toStrictEqual( + Object.keys(defs) + .filter((k) => /^Hero/.test(k)) + .sort(), ) }) diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 1793aa12620..78728ac334d 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -429,22 +429,16 @@ export function fieldsToJSONSchema( type: withNullableJSONSchemaType('array', isRequired), items: hasBlocks ? { - oneOf: (field.blockReferences ?? field.blocks).map((block) => { - if (typeof block === 'string') { - const resolvedBlock = config?.blocks?.find((b) => b.slug === block) - - if (!resolvedBlock) { - return {} - } - - if (!opts.forceInlineBlocks) { - return { - $ref: `#/definitions/${resolveBlockInterfaceName(resolvedBlock, config, collectionIDFieldTypes, i18n)}`, - } - } - - block = resolvedBlock + oneOf: (field.blockReferences ?? field.blocks).map((blockOrReference) => { + const block = + typeof blockOrReference === 'string' + ? config?.blocks?.find((b) => b.slug === blockOrReference) + : blockOrReference + + if (!block) { + return {} } + const blockFieldSchemas = fieldsToJSONSchema( collectionIDFieldTypes, block.flattenedFields, @@ -466,25 +460,11 @@ export function fieldsToJSONSchema( required: ['blockType', ...blockFieldSchemas.required], } - if (!opts.forceInlineBlocks) { - const baseName = blockBaseInterfaceName(block) - const interfaceName = resolveBlockInterfaceName( - block, - config, - collectionIDFieldTypes, - i18n, - ) - if (interfaceName !== baseName) { - blockSchema.description = blockInterfaceCollisionNote(baseName) - } - interfaceNameDefinitions.set(interfaceName, blockSchema) - - return { - $ref: `#/definitions/${interfaceName}`, - } - } - - return blockSchema + return opts.forceInlineBlocks + ? blockSchema + : { + $ref: `#/definitions/${registerBlockInterface(block, blockSchema, interfaceNameDefinitions)}`, + } }), } : {}, @@ -1265,131 +1245,45 @@ function generateAuthOperationSchemas(collections: SanitizedCollectionConfig[]): } } -type BlockForNaming = { flattenedFields: FlattenedField[]; interfaceName?: string; slug: string } - -/** Auto-derived top-level interface name for a block: its `interfaceName` override, else PascalCase of the slug. */ -const blockBaseInterfaceName = (block: { interfaceName?: string; slug: string }): string => - block.interfaceName ?? toWords(block.slug, true) - -/** Builds the `{ type: 'object', ... }` value schema for one block, used to fingerprint its shape. */ -const buildBlockSchema = ( - block: BlockForNaming, - collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, - config: SanitizedConfig | undefined, - i18n: I18n | undefined, -): JSONSchema4 => { - const fields = fieldsToJSONSchema( - collectionIDFieldTypes, - block.flattenedFields, - new Map(), - config, - i18n, - ) - return { - type: 'object', - additionalProperties: false, - properties: { ...fields.properties, blockType: { const: block.slug } }, - required: ['blockType', ...fields.required], - } -} - -/** JSDoc note attached to hash-disambiguated block interfaces in the generated types. */ -export const blockInterfaceCollisionNote = (baseName: string): string => - `Multiple blocks resolve to the \`${baseName}\` interface with different fields, so a content hash is appended to keep the generated types stable and unambiguous. Set a unique \`interfaceName\` on the block to choose the name yourself. See https://payloadcms.com/docs/typescript/generating-types#block-interface-name-collisions` - -const blockInterfaceNamesCache = new WeakMap>() +const hashBlockSchema = (schema: JSONSchema4): string => + createHash('sha256').update(JSON.stringify(schema)).digest('hex').slice(0, 8).toUpperCase() /** - * Final top-level interface name for every block in the config. + * Registers a block's schema as a top-level definition and returns its name. * - * Blocks always generate a top-level interface named after their slug. When two - * blocks share that auto-derived name but have *different* fields, a clean - * shared name would silently mistype one of them — so every colliding block - * gets a stable content-hash suffix (`MyBlock_3F2A`). Unique names stay clean, - * and an explicit `interfaceName` is always used verbatim. Memoized: the - * whole-config walk runs once per config. + * The name is the block's `interfaceName`, or a PascalCase form of its slug. If a different + * block already uses that name, this one gets a content-hash suffix (`Hero_3F2A1B0C`) so the + * two don't overwrite each other. Registering the same block shape again reuses its name. */ -const getBlockInterfaceNames = ( - config: SanitizedConfig, - collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, - i18n: I18n | undefined, -): Map => { - const cached = blockInterfaceNamesCache.get(config) - if (cached) { - return cached +function registerBlockInterface( + block: { interfaceName?: string; slug: string }, + blockSchema: JSONSchema4, + interfaceNameDefinitions: Map, +): string { + const baseName = block.interfaceName ?? toWords(block.slug, true) + const existing = interfaceNameDefinitions.get(baseName) + + // Use the clean name if it is free, or if the block sets an explicit interfaceName. + if (!existing || block.interfaceName) { + interfaceNameDefinitions.set(baseName, blockSchema) + return baseName } - // 1. Collect every block once (collections, globals, config.blocks + nested). - const blocks = new Set() - const collect = (fields: FlattenedField[] | undefined): void => { - for (const field of fields ?? []) { - if (field.type === 'blocks') { - for (const ref of field.blockReferences ?? field.blocks) { - const block = typeof ref === 'string' ? config.blocks?.find((b) => b.slug === ref) : ref - if (block && !blocks.has(block)) { - blocks.add(block) - collect(block.flattenedFields) - } - } - } else if ('flattenedFields' in field && field.flattenedFields) { - collect(field.flattenedFields) - } - } - } - for (const collection of config.collections) { - collect(collection.flattenedFields) - } - for (const global of config.globals) { - collect(global.flattenedFields) - } - for (const block of config.blocks ?? []) { - if (!blocks.has(block)) { - blocks.add(block) - collect(block.flattenedFields) - } - } + const hash = hashBlockSchema(blockSchema) - // 2. Hash each block's shape; group auto-derived names by the shapes they map to. - const hashByBlock = new Map() - const hashesByAutoName = new Map>() - for (const block of blocks) { - const hash = createHash('sha256') - .update(JSON.stringify(buildBlockSchema(block, collectionIDFieldTypes, config, i18n))) - .digest('hex') - .slice(0, 8) - .toUpperCase() - hashByBlock.set(block, hash) - if (!block.interfaceName) { - const name = toWords(block.slug, true) - if (!hashesByAutoName.has(name)) { - hashesByAutoName.set(name, new Set()) - } - hashesByAutoName.get(name)!.add(hash) - } + // The same block shape is already registered under this name, so reuse it. + if (hashBlockSchema(existing) === hash) { + return baseName } - // 3. A name mapping to >1 distinct shape collides → suffix every sharer with its hash. - const names = new Map() - for (const block of blocks) { - const base = blockBaseInterfaceName(block) - const collides = !block.interfaceName && (hashesByAutoName.get(base)?.size ?? 0) > 1 - names.set(block, collides ? `${base}_${hashByBlock.get(block)!}` : base) - } + // A different block already owns the clean name. Disambiguate this one with its hash. + blockSchema.description = `Multiple blocks resolve to the \`${baseName}\` interface with different fields, so a content hash is appended to keep the generated types stable and unambiguous. Set a unique \`interfaceName\` on the block to choose the name yourself. See https://payloadcms.com/docs/typescript/generating-types#block-interface-name-collisions` - blockInterfaceNamesCache.set(config, names) - return names + const uniqueName = `${baseName}_${hash}` + interfaceNameDefinitions.set(uniqueName, blockSchema) + return uniqueName } -/** Final, collision-safe interface name for a single block. See {@link getBlockInterfaceNames}. */ -const resolveBlockInterfaceName = ( - block: BlockForNaming, - config: SanitizedConfig | undefined, - collectionIDFieldTypes: { [key: string]: 'number' | 'string' }, - i18n: I18n | undefined, -): string => - (config && getBlockInterfaceNames(config, collectionIDFieldTypes, i18n).get(block)) ?? - blockBaseInterfaceName(block) - /** * This is used for generating the TypeScript types (payload-types.ts) with the payload generate:types command. */ @@ -1402,8 +1296,9 @@ export function configToJSONSchema( // a mutable Map of top-level definitions in the generated JSON Schema. // - `array`/`group`/named-`tab` fields are registered here when they set // `interfaceName` (otherwise they stay inline). - // - `block` configs always register here, keyed by `block.interfaceName` - // if set, otherwise a PascalCase form of the slug via `toWords`. + // - `block` configs always register here via `registerBlockInterface`, keyed by + // `block.interfaceName` if set, else a PascalCase form of the slug (with a + // content-hash suffix when two different blocks resolve to the same name). const interfaceNameDefinitions: Map = new Map() // Used for relationship fields, to determine whether to use a string or number type for the ID. @@ -1518,15 +1413,8 @@ export function configToJSONSchema( required: ['blockType', ...blockFieldSchemas.required], } - const baseName = blockBaseInterfaceName(block) - const interfaceName = resolveBlockInterfaceName(block, config, collectionIDFieldTypes, i18n) - if (interfaceName !== baseName) { - blockSchema.description = blockInterfaceCollisionNote(baseName) - } - interfaceNameDefinitions.set(interfaceName, blockSchema) - blocksDefinition.properties![block.slug] = { - $ref: `#/definitions/${interfaceName}`, + $ref: `#/definitions/${registerBlockInterface(block, blockSchema, interfaceNameDefinitions)}`, } ;(blocksDefinition.required as string[]).push(block.slug) }