feat: generate separate input and output types for collections and globals#17075
Open
AlessioGr wants to merge 14 commits into
Open
feat: generate separate input and output types for collections and globals#17075AlessioGr wants to merge 14 commits into
AlessioGr wants to merge 14 commits into
Conversation
Contributor
📦 esbuild Bundle Analysis for payloadThis analysis was generated by esbuild-bundle-analyzer. 🤖
Largest pathsThese visualization shows top 20 largest paths in the bundle.Meta file: packages/next/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js
Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js
DetailsNext to the size is how much the size has increased or decreased compared with the base branch of this PR.
|
AlessioGr
commented
Jun 22, 2026
| relationTo: TCollectionSlug | ||
| /** Either the document ID or the full populated document. */ | ||
| value: DataFromCollectionSlug<TCollectionSlug> | number | string | ||
| value: DataFromCollectionSlug<TCollectionSlug> | IDTypeForCollectionSlug<TCollectionSlug> |
Member
Author
There was a problem hiding this comment.
Technically a separate change. number | string was inaccurate - we should be using the new IDTypeForCollectionSlug helper which reads the id type from the generated types.
AlessioGr
commented
Jun 22, 2026
| }, | ||
| }), | ||
| ).type.not.toRaiseError() | ||
| expect(payload.update).type.toBeCallableWith({ |
Member
Author
There was a problem hiding this comment.
Just addressing tstyche deprecation warnings. toRaiseError is deprecated
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Payload generates one TypeScript type per collection and global, describing what you get back when you read. But that same type is also used for what you write (
create/update), and the two shapes differ. As join field, for example, would be included in the output type - but you never write data for a join field. One type can't be right for both.This PR can generate a second type per entity - a write type - turned on with
typescript.generateInputTypes: true. It's off by default:Post- the read type. Unchanged, fully backwards compatible.PostInput- the new write type. Relationships are IDs only; auto-managed, virtual, and join fields are removed;defaultValuefields are optional.Off by default means regenerating an existing project gives the same file as before - nothing breaks unless you turn it on. The only thing using it today is
@payloadcms/plugin-mcp, which gets the correct write schema either way (it builds that at runtime and ignores the flag). Turn it on if you also wantPostInput(andcollectionsInput/globalsInputonConfig) to type a form payload, seed script, or API client.Why opt-in?
create/updatestill use the read type fordata(see below), so the write type isn't part of the main write path. Generating it for everyone by default would add code and a second type to learn, for something most people wouldn't use.Why a separate type - read and write differ in more than relationships
It's not just whether a relationship is populated. Here's every place the write type differs from the read type:
(string | null) | Userstring | nullhasMany, and thevalueof polymorphic{ relationTo, value }.richTextSerializedRelationshipNodeInput/SerializedUploadNodeInput+…Inputblocks - see Rich text below.idcreatedAt/updatedAt_status(drafts)defaultValuefieldsrequiredfield with a default was wrongly mandatory before.collectiondiscriminatorUser-union discriminator; not part of create/update data.Before and after
Given this config:
The read type doesn't change:
With the flag on, you also get the write type:
The write types also appear on
Config, next to the existing maps:How it works
One flag,
variant: 'input' | 'output'(default'output'), runs through the existing builders in configToJSONSchema.ts. No separate code path; the read side is unchanged.fieldsToJSONSchema- for input: relationship/upload fields become IDs only,defaultValuefields become optional, virtual/join fields are skipped. Named interfaces (groups, tabs, arrays, selects, blocks) only get anInputsuffix when their write shape actually differs - if it's identical they share one type, so a relationship-freeMetagets noMetaInputcopy.entityToJSONSchema- for input:idbecomes optional;createdAt,updatedAt,_status, and the authcollectionfield are removed; the name gets anInputsuffix. Note: optional ≠ nullable. A field input only makes optional (id, defaulted fields) stays non-null (id?: string, notid?: string | null), soPostInputstays a subset of the read type and can still be passed tocreate/update.registerBlockInterfaceandentityToStandaloneJSONSchemaalso take thevariant: a blocks field givesHeroandHeroInputwhen they differ, oneHerowhen they don't.configToJSONSchema- when the flag is on, also writes${slug}_inputtypes and addscollectionsInput/globalsInputtoConfig;json-schema-to-typescriptturns those intoPostInput,MenuInput, etc.field.jsonSchematransforms now getvariant, so a custom transform can differ for input vs output.jsonSchemacallback getsvarianttoo - see below.SchemaVarianttype and thegenerateInputTypesflag.Rich text
Lexical handles both variants. Its types come from named
Serialized*Nodetypes viatsType, and most are generic over their children or block fields, so the input union just plugs in the input types:Only two node types put the full document directly in their definition -
SerializedRelationshipNodeandSerializedUploadNode- so those get a hand-written ID-only version (SerializedRelationshipNodeInput/SerializedUploadNodeInput). Everything else (text, paragraph, heading, lists, tables, links, blocks) is reused.Both also stopped hardcoding
number | stringfor a relationship'svalue- they now use each collection's real ID type: output nodes useConfig['collections'][TSlug]['id'] | Config['collections'][TSlug], input nodes useConfig['collections'][TSlug]['id'](backed by a new exportedIDTypeForCollectionSlug<TSlug>helper).Node unions are named by a content hash; an input union is
LexicalNodes_<hash>_Inputto tell it apart from the output one. An editor with no relationships has identical input and output unions, so they share oneLexicalNodes_<hash>(no_Inputcopy). Same for blocks and named interfaces: theInputsuffix only appears when the write shape differs.MCP cleanup
This is the main place the input types are used today.
@payloadcms/plugin-mcpnow callsentityToStandaloneJSONSchema({ variant: 'input' }). Because that variant is already correct, all the schema cleanup MCP used to do is gone - the input variant has zero collection$refs anywhere, including in rich text:removeVirtualFieldsFromSchema- input already omits virtual fields.removeManagedFields- input already omits the managedid.relationshipsToIds- input relationship/upload values are already IDs, so there's nooneOf: [id, $ref]left to simplify.Why
create/updatestill use the read typeThe write types exist and MCP uses them, but the Local API (
create,update,updateByID,duplicate,updateGlobal) still typesdataagainst the read type (Post, notPostInput). I decided against switching it, for two reasons.Read-modify-write is common and valid at runtime. With the write type, it stops compiling as soon as you read at
depth > 0:Spreading a read document (
data: { ...post, title }) or writing rich text back breaks the same way.The write type would be stricter than the runtime. Payload accepts a populated relationship on write and pulls the ID out. Rejecting that in the types rejects working code, pushing people to
as any- worse than a loose type.We may switch the Local API to a real write type later, but that needs read-modify-write solved first: the write type would accept a relationship as either an ID or the full document (
id | doc, what the runtime already does) while keepingid, defaulted, and managed fields optional. Bigger change, follow-up. For now the write types are safe to use - they're assignable to whatcreate/updateaccept.Where the write types are safe to use today
@payloadcms/plugin-mcpuses the input schema directly.Config['collectionsInput']['posts']or the exportedPostInputto type a write helper, form payload, or seed script.A few other notes
What the write type keeps vs. removes. It keeps everything you can write, as optional - including fields only writable with
overrideAccess(salt,hash,resetPasswordToken, …) and upload metadata (url,filename,sizes, …). Removing those was rejected: the only "never written" signal is a field-levelaccess: () => false, whichoverrideAccessbypasses, so removing them would make the type stricter than the runtime. It removes the truly read-only stuff:virtualandjoinfields, the authcollectionfield, and the auto-managedcreatedAt/updatedAt/_status- those three are settable viadatabut rarely set on purpose, so they're dropped like Prisma/Drizzle drop generated timestamps. (One honest inconsistency: we dropcreatedAt/updatedAt/_statusbut keepsalt/hash, which are just as managed - worth reconciling if we revisit the write type.)Off by default.
generateInputTypesdefaults tofalse: regenerating an existing project gives the same output as before, so this PR breaks nothing unless you turn it on. Here, onlytest/typesopts in (for the type tests); every other suite regenerates unchanged. The flag's JSDoc has a@todofor turning it on by default (the read-modify-write problem above).