payloadcms / payloadcms/payload
`typescriptSchema` cannot promote a field into the `required` array; `admin.condition` workaround for GraphQL nullability silently breaks TypeScript type generation
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 44.8k
- Forks
- 4.2k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 53
Description
Describe the Bug
Two interacting bugs prevent typescriptSchema from making a field required in the generated TypeScript interface:
-
typescriptSchemais an opt-out-only mechanism — therequiredproperty returned by the callback is never used to promote a field into therequiredFieldNamesset. The condition that adds a field name torequiredFieldNamesis gated on the pre-computedisRequiredboolean (computed beforetypescriptSchemaruns), so returning{ required: true }from the callback has no effect if the field was not already required according to Payload's own logic. -
admin.conditionunconditionally suppressesisRequired—fieldIsRequired()short-circuits tofalsefor any field withadmin.conditiondefined, evencondition: () => true. This is the workaround commonly recommended for GraphQL non-nullability (see #15811), which means the two documented workarounds actively cancel each other out.
Relevant source (packages/payload/src/utilities/configToJSONSchema.ts)
// 1. isRequired is computed BEFORE typescriptSchema runs
const fieldIsRequired = (field) => {
const isConditional = Boolean(field?.admin?.condition) // ← kills required for any conditional field
if (isConditional) return false
// …
}
// 2. typescriptSchema runs and CAN replace the whole fieldSchema object
if ('typescriptSchema' in field && field?.typescriptSchema?.length) {
for (const schema of field.typescriptSchema) {
fieldSchema = schema({ jsonSchema: fieldSchema })
}
}
// 3. But the required promotion gate is still the pre-computed isRequired
if (isRequired && fieldSchema.required !== false) { // ← isRequired is false → short-circuits
requiredFieldNames.add(field.name)
}
The fieldSchema.required !== false sub-condition is only an opt-out escape hatch (it lets a required field suppress itself from the array). It cannot promote an optional field to required because the outer isRequired && short-circuits first.
Expected behavior
A field configured with typescriptSchema: [() => ({ type: 'number' })] combined with required: true on the field config should be generated as myNumber: number (non-optional).
Alternatively (the current documented workaround pattern), returning a schema with a truthy required property from typescriptSchema should add the field to the required array.
Actual behavior
The field is generated as myNumber?: number (optional) regardless of both required: true on the field and what typescriptSchema returns — as long as admin.condition is present.
Link to the code that reproduces this issue
https://github.com/Murz-forks/payload-issues/tree/issue-graphql-required
Reproduction Steps
Add the following fields to test/_community/collections/Posts/index.ts:
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
// ── Reproduction case 1 ──────────────────────────────────────────────────
// A required number field with admin.condition (GraphQL nullability
// workaround) and a typescriptSchema override.
// Expected generated type: `myNumberWithCondition: number` (non-optional)
// Actual generated type: `myNumberWithCondition?: number` (optional)
{
name: 'myNumberWithCondition',
type: 'number',
required: true,
defaultValue: 10,
typescriptSchema: [() => ({ type: 'number' })],
admin: {
// Workaround from https://github.com/payloadcms/payload/discussions/15811
// to make the field non-null in GraphQL — but it silently breaks
// TypeScript type generation because fieldIsRequired() returns false
// for any field with admin.condition defined.
condition: () => true,
},
},
// ── Reproduction case 2 ──────────────────────────────────────────────────
// Same field WITHOUT admin.condition. field.required: true is respected
// and the field IS required in the generated types — showing that
// typescriptSchema cannot independently opt a field in; only field.required
// can. This case is correct and included for comparison only.
{
name: 'myNumber',
type: 'number',
required: true,
defaultValue: 10,
typescriptSchema: [() => ({ type: 'number' })],
// No admin.condition → myNumber: number ✓
},
],
}
Then run:
pnpm payload generate:types
Inspect test/_community/payload-types.ts — the Post interface will contain:
myNumberWithCondition?: number; // ← BUG: should be `myNumberWithCondition: number`
myNumber: number; // ← correct (no admin.condition)
Root cause
fieldIsRequired() (line ~8 of configToJSONSchema.ts) unconditionally returns false for any field where admin.condition is defined. The comment suggests this is intentional — conditional fields may not appear in the document. However condition: () => true is a workaround to trigger GraphQL non-nullability inference; the field is always present. More critically, there is no mechanism for typescriptSchema to override the requiredFieldNames set regardless, because the check is:
if (isRequired && fieldSchema.required !== false)
// ^^^^^^^^^^
// set before typescriptSchema runs; the typescriptSchema output is never
// consulted for the required promotion decision
Proposed fix
Change the condition in fieldsToJSONSchema from:
if (isRequired && fieldSchema.required !== false) {
requiredFieldNames.add(field.name)
}
to:
if ((isRequired || fieldSchema.required === true) && fieldSchema.required !== false) {
requiredFieldNames.add(field.name)
}
This allows typescriptSchema to opt a field in to required by returning { required: true, … }, while preserving the existing opt-out escape hatch (required: false still suppresses).
Workaround (until fixed)
Remove admin.condition and accept that the GraphQL type will be nullable. Without admin.condition, fieldIsRequired() returns true and required: true on the field is respected in the generated types:
{
name: 'myNumber',
type: 'number',
required: true,
defaultValue: 10,
// No admin.condition — generated as `myNumber: number` ✓
// GraphQL type will be nullable, but TypeScript will be correct.
}
Which area(s) are affected?
area: graphql
Environment Info
Binaries:
Node: 24.15.0
npm: 11.12.1
Yarn: N/A
pnpm: 11.1.3
Relevant Packages:
payload: 3.83.0
next: 16.2.6
@payloadcms/db-mongodb: 3.83.0
@payloadcms/graphql: 3.83.0
@payloadcms/live-preview: 3.83.0
@payloadcms/live-preview-react: 3.83.0
@payloadcms/next/utilities: 3.83.0
@payloadcms/plugin-import-export: 3.83.0
@payloadcms/plugin-nested-docs: 3.83.0
@payloadcms/plugin-redirects: 3.83.0
@payloadcms/plugin-search: 3.83.0
@payloadcms/plugin-seo: 3.83.0
@payloadcms/richtext-lexical: 3.83.0
@payloadcms/sdk: 3.83.0
@payloadcms/translations: 3.83.0
@payloadcms/ui/shared: 3.83.0
react: 19.2.1
react-dom: 19.2.1
Operating System:
Platform: linux
Arch: x64
Version: #15-Ubuntu SMP PREEMPT_DYNAMIC Wed Apr 22 16:06:43 UTC 2026
Available memory (MB): 31515
Available CPU cores: 16
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/payload/src/utilities/configToJSONSchema.ts, focusing on fieldIsRequired, the typescriptSchema callback, and requiredFieldNames. Reproduce the issue by updating test/_community/collections/Posts/index.ts and running pnpm payload generate:types, then inspect test/_community/payload-types.ts. Done means the conditional required field is non-optional while the existing required:false opt-out remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- graphql, typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100