Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fair-intents-infer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@conform-to/react': minor
---

Preserve inferred form and field shapes when passing values to the reset, update, insert, and remove intents.
78 changes: 37 additions & 41 deletions packages/conform-react/future/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,60 +691,54 @@ export type IntentHandlerPayload<

export type SubmitIntent = (value?: string) => void;

type ResetIntentOptions<FormShape> = {
defaultValue?: DefaultValue<
FormShape extends Record<string, any> ? FormShape : never
>;
};

export interface ResetIntent extends TypedIntentDefinition {
dispatch<
FormShape extends Record<string, any> = this['FormShape'] extends Record<
string,
any
>
? this['FormShape']
: never,
>(options?: {
defaultValue?: DefaultValue<FormShape>;
}): void;
dispatch(options?: ResetIntentOptions<this['FormShape']>): void;
}

export type ValidateIntent = (name?: string) => void;

export type IsUntypedFormShape<FormShape> = unknown extends FormShape
? true
: string extends keyof FormShape
? true
: false;
type UpdateIntentOptions<FormShape, FieldShape> =
| {
name?: undefined;
index?: undefined;
value: DefaultValue<NoInfer<FormShape>>;
}
| {
name: FieldName<FieldShape>;
index?: undefined;
value: DefaultValue<NoInfer<FieldShape>>;
}
| {
name: FieldName<
FieldShape extends Array<any> | null | undefined ? FieldShape : never
>;
index: number;
value: unknown extends FieldShape
? unknown
: NonNullable<NoInfer<FieldShape>> extends Array<infer ItemShape>
? DefaultValue<ItemShape>
: never;
};

export interface UpdateIntent extends TypedIntentDefinition {
dispatch<FieldShape = this['FormShape']>(
options: IsUntypedFormShape<this['FormShape']> extends true
? {
name?: string | undefined;
index?: number | undefined;
value: unknown;
}
:
| {
name?: FieldName<FieldShape>;
index?: undefined;
value: DefaultValue<FieldShape>;
}
| {
name: FieldName<
FieldShape extends Array<any> | null | undefined
? FieldShape
: never
>;
index: number;
value: NonNullable<FieldShape> extends Array<infer ItemShape>
? DefaultValue<ItemShape>
: never;
},
dispatch<FieldShape = unknown>(
options: UpdateIntentOptions<this['FormShape'], FieldShape>,
): void;
}

export interface InsertIntent extends TypedIntentDefinition {
dispatch<FieldShape extends Array<any> | null | undefined>(options: {
name: FieldName<FieldShape>;
index?: number;
defaultValue?: NonNullable<FieldShape> extends Array<infer ItemShape>
defaultValue?: NonNullable<NoInfer<FieldShape>> extends Array<
infer ItemShape
>
? DefaultValue<ItemShape>
: never;
from?: string;
Expand All @@ -757,7 +751,9 @@ export interface RemoveIntent extends TypedIntentDefinition {
name: FieldName<FieldShape>;
index: number;
onInvalid?: 'revert' | 'insert';
defaultValue?: NonNullable<FieldShape> extends Array<infer ItemShape>
defaultValue?: NonNullable<NoInfer<FieldShape>> extends Array<
infer ItemShape
>
? DefaultValue<ItemShape>
: never;
}): void;
Expand Down
74 changes: 74 additions & 0 deletions packages/conform-react/tests/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,80 @@ test('DefaultValue', () => {
assertType<DefaultValue<IntentObject>>({ intent: 'delete' });
});

test('literal default values in intents', () => {
type FormShape = {
status: 'draft' | 'published';
items: Array<{ role: 'admin' | 'member' }>;
};

const intent = {} as IntentDispatcher<FormShape>;
const untypedIntent = {} as IntentDispatcher<Record<string, any>>;
const statusName = 'status' as FieldName<FormShape['status']>;
const itemsName = 'items' as FieldName<FormShape['items']>;

assertType<void>(intent.reset({ defaultValue: { status: 'published' } }));
// @ts-expect-error programmatic reset should reject unsupported literals
intent.reset({ defaultValue: { status: 'archived' } });

assertType<void>(intent.update({ value: { status: 'published' } }));
// @ts-expect-error whole-form updates should reject unsupported literals
intent.update({ value: { status: 'archived' } });

assertType<void>(intent.update({ name: statusName, value: 'draft' }));
// @ts-expect-error branded field updates should reject unsupported literals
intent.update({ name: statusName, value: 'archived' });

assertType<void>(
intent.update({ name: itemsName, index: 0, value: { role: 'member' } }),
);
// @ts-expect-error indexed field updates should reject unsupported literals
intent.update({ name: itemsName, index: 0, value: { role: 'owner' } });

assertType<void>(
untypedIntent.update({ name: statusName, value: 'published' }),
);
// @ts-expect-error branded names should remain typed on an untyped form
untypedIntent.update({ name: statusName, value: 'archived' });

assertType<void>(
untypedIntent.update({
name: itemsName,
index: 0,
value: { role: 'admin' },
}),
);
// @ts-expect-error indexed branded names should remain typed on an untyped form
untypedIntent.update({ name: itemsName, index: 0, value: { role: 'owner' } });

assertType<void>(
intent.insert({ name: itemsName, defaultValue: { role: 'admin' } }),
);
// @ts-expect-error programmatic inserts should reject unsupported literals
intent.insert({ name: itemsName, defaultValue: { role: 'owner' } });

assertType<void>(
intent.remove({
name: itemsName,
index: 0,
onInvalid: 'insert',
defaultValue: { role: 'member' },
}),
);
intent.remove({
name: itemsName,
index: 0,
onInvalid: 'insert',
// @ts-expect-error programmatic fallback inserts should reject unsupported literals
defaultValue: { role: 'owner' },
});

// DOM-derived metadata remains intentionally broad
const metadata = {} as FieldMetadata<FormShape['status']>;

assertType<string>(metadata.defaultValue);
assertType<unknown>(metadata.defaultPayload);
});

test('FormOptions', () => {
type TestSchema = { name: string; email: string };

Expand Down
Loading