Skip to content

Commit 1016cd0

Browse files
authored
fix: typescript requires fields when draft: true despite passing draft: true (#14271)
### What? Fixes TypeScript types for `payload.create()` to allow omitting required fields when `draft: true` is specified, aligning type checking with runtime validation behavior. ### Why? According to the [documentation](https://payloadcms.com/docs/versions/drafts#updating-or-creating-drafts), when creating drafts, required fields don't need to be provided because validation is skipped by default (unless `versions.drafts.validate` is explicitly set to `true`). However, TypeScript was still enforcing required fields at the type level, causing type errors even when `draft: true` was set. ### How? - Added new types `DraftDataFromCollectionSlug` and `DraftDataFromCollection` that make all fields optional - Changed `payload.create` options to use a discriminated union type: - When `draft: true` → uses `DraftDataFromCollectionSlug` (all fields optional) - When `draft: false` or omitted → uses `RequiredDataFromCollectionSlug` (required fields enforced) The fix uses TypeScript's discriminated unions to provide accurate type inference based on the `draft` parameter value. Fixes #12578
1 parent 05a869d commit 1016cd0

File tree

3 files changed

+100
-9
lines changed

3 files changed

+100
-9
lines changed

packages/payload/src/collections/config/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ export type RequiredDataFromCollection<TData extends JsonObject> = MarkOptional<
7373
export type RequiredDataFromCollectionSlug<TSlug extends CollectionSlug> =
7474
RequiredDataFromCollection<DataFromCollectionSlug<TSlug>>
7575

76+
/**
77+
* Helper type for draft data - makes all fields optional except auto-generated ones
78+
* When creating a draft, required fields don't need to be provided as validation is skipped
79+
*/
80+
export type DraftDataFromCollection<TData extends JsonObject> = Partial<
81+
MarkOptional<TData, 'createdAt' | 'deletedAt' | 'id' | 'sizes' | 'updatedAt'>
82+
>
83+
84+
export type DraftDataFromCollectionSlug<TSlug extends CollectionSlug> = DraftDataFromCollection<
85+
DataFromCollectionSlug<TSlug>
86+
>
87+
7688
export type HookOperationType =
7789
| 'autosave'
7890
| 'count'

packages/payload/src/collections/operations/local/create.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { File } from '../../../uploads/types.js'
99
import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js'
1010
import type {
1111
DataFromCollectionSlug,
12+
DraftDataFromCollectionSlug,
1213
RequiredDataFromCollectionSlug,
1314
SelectFromCollectionSlug,
1415
} from '../../config/types.js'
@@ -25,7 +26,7 @@ import { getFileByPath } from '../../../uploads/getFileByPath.js'
2526
import { createLocalReq } from '../../../utilities/createLocalReq.js'
2627
import { createOperation } from '../create.js'
2728

28-
export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> = {
29+
type BaseOptions<TSlug extends CollectionSlug, TSelect extends SelectType> = {
2930
/**
3031
* the Collection slug to operate against.
3132
*/
@@ -37,10 +38,6 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
3738
* to determine if it should run or not.
3839
*/
3940
context?: RequestContext
40-
/**
41-
* The data for the document to create.
42-
*/
43-
data: RequiredDataFromCollectionSlug<TSlug>
4441
/**
4542
* [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields.
4643
*/
@@ -55,10 +52,6 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
5552
* you can disable the email that is auto-sent
5653
*/
5754
disableVerificationEmail?: boolean
58-
/**
59-
* Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api)
60-
*/
61-
draft?: boolean
6255
/**
6356
* If you want to create a document that is a duplicate of another document
6457
*/
@@ -115,6 +108,29 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
115108
user?: Document
116109
}
117110

111+
export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
112+
| ({
113+
/**
114+
* The data for the document to create.
115+
*/
116+
data: RequiredDataFromCollectionSlug<TSlug>
117+
/**
118+
* Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api)
119+
*/
120+
draft?: false
121+
} & BaseOptions<TSlug, TSelect>)
122+
| ({
123+
/**
124+
* The data for the document to create.
125+
* When creating a draft, required fields are optional as validation is skipped by default.
126+
*/
127+
data: DraftDataFromCollectionSlug<TSlug>
128+
/**
129+
* Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api)
130+
*/
131+
draft: true
132+
} & BaseOptions<TSlug, TSelect>)
133+
118134
export async function createLocal<
119135
TSlug extends CollectionSlug,
120136
TSelect extends SelectFromCollectionSlug<TSlug>,

test/versions/int.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,69 @@ describe('Versions', () => {
10831083
})
10841084
})
10851085

1086+
describe('Draft Types', () => {
1087+
it('should allow creating drafts without required fields', async () => {
1088+
// This test validates that when draft: true is set, required fields become optional
1089+
// TypeScript should not complain about missing 'description' field even though it's required
1090+
const draft = await payload.create({
1091+
collection: 'draft-posts',
1092+
data: {
1093+
title: 'Draft without description',
1094+
// description is required but omitted - should work with draft: true
1095+
},
1096+
draft: true,
1097+
})
1098+
1099+
expect(draft.title).toBe('Draft without description')
1100+
// Different databases return null vs undefined for missing fields
1101+
expect(draft.description).toBeFalsy()
1102+
expect(draft._status).toBe('draft')
1103+
})
1104+
1105+
it('should require all required fields when draft is false', async () => {
1106+
// This validates that required fields are still enforced when draft is false
1107+
await expect(
1108+
// @ts-expect-error - description is required when not creating a draft
1109+
payload.create({
1110+
collection: 'draft-posts',
1111+
data: {
1112+
title: 'Published without description',
1113+
},
1114+
draft: false,
1115+
}),
1116+
).rejects.toThrow(ValidationError)
1117+
})
1118+
1119+
it('should require all required fields when draft is not specified', async () => {
1120+
// This validates that required fields are still enforced when draft option is omitted
1121+
await expect(
1122+
// @ts-expect-error - description is required when draft option is not specified
1123+
payload.create({
1124+
collection: 'draft-posts',
1125+
data: {
1126+
title: 'Post without description',
1127+
},
1128+
}),
1129+
).rejects.toThrow(ValidationError)
1130+
})
1131+
1132+
it('should allow all fields to be optional with draft: true', async () => {
1133+
// Test that even fields nested in groups can be omitted
1134+
const draft = await payload.create({
1135+
collection: 'draft-posts',
1136+
data: {
1137+
// Both title and description are required but omitted
1138+
},
1139+
draft: true,
1140+
})
1141+
1142+
expect(draft._status).toBe('draft')
1143+
// Different databases return null vs undefined for missing fields
1144+
expect(draft.title).toBeFalsy()
1145+
expect(draft.description).toBeFalsy()
1146+
})
1147+
})
1148+
10861149
describe('Max Versions', () => {
10871150
// create 2 documents with 3 versions each
10881151
// expect 2 documents with 2 versions each

0 commit comments

Comments
 (0)