Skip to content

feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782

Draft
AlessioGr wants to merge 20 commits into
mainfrom
feat/accurate-lexical-types
Draft

feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782
AlessioGr wants to merge 20 commits into
mainfrom
feat/accurate-lexical-types

Conversation

@AlessioGr
Copy link
Copy Markdown
Member

This PR makes richText fields properly typed in payload-types.ts. Until now, every richText field was typed as a vague { [k: string]: unknown } blob. TypeScript couldn't help you do anything with rich text content — no autocomplete on node fields, no narrowing by type, no safe converters or renderers.

Now each node has a real interface (SerializedTextNode, SerializedHeadingNode, SerializedBlockNode, etc.) and the field is typed as a union of all the nodes its editor uses.

What the generated types look like

Before.

content?: {
  [k: string]: unknown;
  root: { [k: string]: unknown };
} | null;

After.

content?: LexicalRichText<LexicalNodes_0BDB72B5> | null;

export type LexicalNodes_0BDB72B5 =
  | SerializedTextNode
  | SerializedTabNode
  | SerializedLineBreakNode
  | SerializedParagraphNode<LexicalNodes_0BDB72B5>
  | SerializedHorizontalRuleNode
  | SerializedUploadNode<'uploads' | 'uploads2'>
  | SerializedQuoteNode<LexicalNodes_0BDB72B5>
  | SerializedRelationshipNode<'posts' | 'users' | /* ... */>
  | SerializedAutoLinkNode<LexicalNodes_0BDB72B5>
  | SerializedLinkNode<LexicalNodes_0BDB72B5>
  | SerializedListNode<LexicalNodes_0BDB72B5>
  | SerializedListItemNode<LexicalNodes_0BDB72B5>
  | SerializedHeadingNode<LexicalNodes_0BDB72B5>;

The union name is a hash of its own contents, so two fields with the same set of nodes share one alias instead of duplicating.

Breaking changes

Most projects need no code changes — regenerate payload-types.ts and everything keeps working, just with tighter types. The renames below only matter if you wrote a custom rich-text adapter, your own type-generation script, or a custom lexical feature.

Every block now auto-generates a top-level interface

Before, a block only got a top-level interface in payload-types.ts if you set interfaceName on it. Without that, the block's fields were inlined wherever the block appeared.

Now every block always emits a top-level interface. The name comes from the slug as PascalCase ('content-block'ContentBlock). interfaceName still works, but it's an override for that default name rather than the switch that enables generation.

Same rule applies to lexical block nodes — they now emit as SerializedBlockNode<…> / SerializedInlineBlockNode<…> referencing the per-block interface, instead of inlining a { [k: string]: unknown }.

The only thing to watch for is name collisions. If a block's auto-derived name clashes with another type in your generated file, set interfaceName to disambiguate.

Field typescriptSchema is now jsonSchema

The old name lied — it accepted JSON Schema, not TypeScript.

  {
    name: 'tags',
    type: 'json',
-   typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })],
+   jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })],
  }

Rich-text adapter outputSchema is now jsonSchema

Same rename on the editor side. The arguments object also gains typeStringDefinitions: Set<string> — adapters use it to add raw TS source to payload-types.ts.

  myAdapter: RichTextAdapter = {
-   outputSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired }) => {
+   jsonSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired, typeStringDefinitions }) => {
      // return JSONSchema4
    },
  }

@payloadcms/richtext-lexical already uses the new name. Only third-party adapters need to be updated.

configToJSONSchema returns an object now

It used to return a JSONSchema4. It now returns { jsonSchema, typeStringDefinitions }.

- const schema = configToJSONSchema(sanitizedConfig, 'text')
+ const { jsonSchema: schema, typeStringDefinitions } = configToJSONSchema(sanitizedConfig, 'text')

fieldsToJSONSchema takes one object instead of 6 positional args

- fieldsToJSONSchema(
-   collectionIDFieldTypes,
-   fields,
-   interfaceNameDefinitions,
-   config,
-   i18n,
-   { forceInlineBlocks: true },
- )
+ fieldsToJSONSchema({
+   collectionIDFieldTypes,
+   config,
+   fields,
+   forceInlineBlocks: true,
+   i18n,
+   interfaceNameDefinitions,
+   typeStringDefinitions,
+ })

entityToJSONSchema got a new required argument

typeStringDefinitions is now a required positional argument at position 5. The old opts object becomes an optional forceInlineBlocks?: boolean at the end.

  entityToJSONSchema(
    config,
    entity,
    interfaceNameDefinitions,
    defaultIDType,
+   typeStringDefinitions,
    collectionIDFieldTypes,
    i18n,
-   { forceInlineBlocks: true },
+   true,
  )

Lexical: modifyOutputSchema is gone

Features used to mutate the field's whole JSON Schema after the fact via generatedTypes.modifyOutputSchema. Now each node contributes its own schema directly:

  export const MyFeature = createServerFeature({
    feature: () => ({
-     generatedTypes: {
-       modifyOutputSchema: ({ currentSchema, interfaceNameDefinitions }) => {
-         return currentSchema
-       },
-     },
      nodes: [
        createNode({
          node: MyNode,
+         jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => {
+           typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { /* ... */ }`)
+           return elementNodeSchema({ nodeType: 'my', tsType: `SerializedMyNode<${nodeUnionName}>` })
+         },
        }),
      ],
    }),
  })

Custom nodes without a jsonSchema function fall back to { [k: string]: unknown }, so leaving it out is fine — your types just stay loose for that node.

In practice only BlocksFeature ever used modifyOutputSchema, and the comment in its implementation admitted it: "we don't actually use the JSON Schema itself in the generated types yet". It was registering interfaces for blocks as a side effect, with the wrong (loose) shape — which is why block id/blockName used to be too permissive in the old types.

Lexical: registering the same node twice now throws

sanitizeServerFeatures rejects two features registering the same node type. Before, it silently kept the last one. The default editor is unaffected — the built-in lists features already coordinate via shouldRegisterListBaseNodes.

If you had a custom feature that re-registered an existing node (e.g. to swap in a subclass), use lexical's LexicalNodeReplacement shape:

  createNode({
-   node: MyExtendedHeadingNode,
+   node: { replace: HeadingNode, with: (node) => new MyExtendedHeadingNode(node.__tag) },
  })

How features contribute types

Each feature attaches a jsonSchema function to its node via createNode. The function gets a helper for the shared element shape and a Set<string> it can dump raw TS source into:

const SERIALIZED_QUOTE_NODE_TS = `export interface SerializedQuoteNode<TChildren> extends SerializedLexicalElementBase<TChildren> {
  type: 'quote';
}`

export const quoteNodeJSONSchema: JSONSchemaFn = ({
  elementNodeSchema,
  nodeUnionName,
  typeStringDefinitions,
}) => {
  typeStringDefinitions.add(SERIALIZED_QUOTE_NODE_TS)
  return elementNodeSchema({
    nodeType: 'quote',
    tsType: `SerializedQuoteNode<${nodeUnionName}>`,
  })
}

The same TS source string from many nodes only lands in the output once — Set<string> deduplicates for free. Nodes without jsonSchema stay as { [k: string]: unknown }, so features can opt in node by node.

Internal refactor changes

  • packages/richtext-lexical/src/index.ts shrank by ~700 lines. The four RichTextHooks implementations moved to hooks.ts (getLexicalHooks({ editorConfig })), and the field-level JSON Schema builder moved to types/schema.ts (getFieldToJSONSchema({ editorConfig })). What's left in index.ts is mostly re-exports plus the lexicalEditor() factory.
  • Shared lexical types live in types/builtInNodes.ts (SerializedLexicalElementBase, LexicalElementFormat, …). Per-node helpers live next to their schemas under features/*/server/schema.ts. nodeTypes.ts re-exports from the new locations.

AlessioGr added 3 commits May 28, 2026 21:21
…-types

# Conflicts:
#	docs/fields/array.mdx
#	docs/fields/blocks.mdx
#	docs/fields/checkbox.mdx
#	docs/fields/code.mdx
#	docs/fields/date.mdx
#	docs/fields/email.mdx
#	docs/fields/group.mdx
#	docs/fields/join.mdx
#	docs/fields/json.mdx
#	docs/fields/number.mdx
#	docs/fields/point.mdx
#	docs/fields/radio.mdx
#	docs/fields/relationship.mdx
#	docs/fields/rich-text.mdx
#	docs/fields/select.mdx
#	docs/fields/text.mdx
#	docs/fields/textarea.mdx
#	docs/fields/upload.mdx
#	docs/migration-guide/v4.mdx
#	packages/payload/src/admin/RichText.ts
#	packages/richtext-lexical/src/features/blockquote/server/index.ts
#	packages/richtext-lexical/src/features/blocks/client/component/index.tsx
#	packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx
#	packages/richtext-lexical/src/features/blocks/client/nodes/BlocksNode.tsx
#	packages/richtext-lexical/src/features/blocks/client/nodes/InlineBlocksNode.tsx
#	packages/richtext-lexical/src/features/blocks/server/index.ts
#	packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx
#	packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx
#	packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts
#	packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts
#	packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx
#	packages/richtext-lexical/src/features/experimental_table/server/index.ts
#	packages/richtext-lexical/src/features/heading/server/index.ts
#	packages/richtext-lexical/src/features/horizontalRule/server/nodes/HorizontalRuleNode.tsx
#	packages/richtext-lexical/src/features/link/nodes/types.ts
#	packages/richtext-lexical/src/features/lists/plugin/index.tsx
#	packages/richtext-lexical/src/features/relationship/server/nodes/RelationshipNode.tsx
#	packages/richtext-lexical/src/features/typesServer.ts
#	packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx
#	packages/richtext-lexical/src/field/Diff/converters/upload/index.tsx
#	packages/richtext-lexical/src/lexical/config/server/sanitize.ts
#	packages/richtext-lexical/src/types/nodeTypes.ts
#	packages/richtext-lexical/src/types/schema.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant