Files
ComfyUI_frontend/src/schemas/nodeDefSchema.ts
Johnpaul Chiwetelu b9f75b6cc8 fix: improve type safety in type definitions (#7337)
## Summary

- Replace `any` types with proper TypeScript types in core type
definitions
- Add generics to `SettingParams`, `setting.get<T>()`, and
`setting.set<T>()` for type-safe setting access
- Add `NodeExecutionOutput` interface for `onExecuted` callback
- Change function parameters from `any` to `unknown` where appropriate

## Type Research

Performed GitHub code search across custom node repositories to
understand actual usage patterns:

**`onExecuted` output properties** (used in rgthree-comfy,
ComfyUI-KJNodes, ComfyUI-ExLlama-Nodes, comfy_mtb, etc.):
- `output.text` - string or string array for text display nodes
- `output.images`, `output.audio`, `output.video` - media outputs
- `output.ui.items` - complex debug/preview data with `input`, `text`,
`b64_images`

**`extensionManager.setting.get/set`** (used in ComfyUI-Crystools,
ComfyUI-Copilot, etc.):
- Returns various types (boolean, number, string, objects)
- Now uses generics: `setting.get<boolean>('MyExt.Setting')`

**`ComfyExtension` custom properties** (used in rgthree-comfy,
ComfyUI-Manager):
- `aboutPageBadges`, `commands`, custom methods
- Kept as `any` index signature since extensions add arbitrary
properties

## Changes

| File | Change |
|------|--------|
| `extensionTypes.ts` | Generic `setting.get<T>()` and
`setting.set<T>()`, typed Toast options |
| `litegraph-augmentation.d.ts` | `onExecuted(output:
NodeExecutionOutput)` |
| `metadataTypes.ts` | GLTF index signatures `any` → `unknown` |
| `apiSchema.ts` | New `NodeExecutionOutput` interface |
| `settings/types.ts` | `SettingOnChange<T>`, `SettingMigration<T>`,
`SettingParams<TValue>` |
| `nodeDefSchema.ts` | `validateComfyNodeDef(data: unknown)` |
| `workflowSchema.ts` | `isSubgraphDefinition(obj: unknown)` |
| `telemetry/types.ts` | `checkForCompletedTopup(events: AuditLog[])` |

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm test:unit` passes (3732 tests)
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7337-fix-improve-type-safety-in-type-definitions-2c66d73d365081bdbc30e916cac607d6)
by [Unito](https://www.unito.io)
2025-12-11 22:10:01 +01:00

283 lines
8.4 KiB
TypeScript

import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
query_params: z.record(z.string(), z.string()).optional(),
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
})
export const zBaseInputOptions = z
.object({
default: z.any().optional(),
defaultInput: z.boolean().optional(),
display_name: z.string().optional(),
forceInput: z.boolean().optional(),
tooltip: z.string().optional(),
socketless: z.boolean().optional(),
hidden: z.boolean().optional(),
advanced: z.boolean().optional(),
widgetType: z.string().optional(),
/** Backend-only properties. */
rawLink: z.boolean().optional(),
lazy: z.boolean().optional()
})
.passthrough()
const zNumericInputOptions = zBaseInputOptions.extend({
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
default: z.union([z.number(), z.array(z.number())]).optional(),
display: z.enum(['slider', 'number', 'knob']).optional()
})
export const zIntInputOptions = zNumericInputOptions.extend({
/**
* If true, a linked widget will be added to the node to select the mode
* of `control_after_generate`.
*/
control_after_generate: z.boolean().optional()
})
export const zFloatInputOptions = zNumericInputOptions.extend({
round: z.union([z.number(), z.literal(false)]).optional()
})
export const zBooleanInputOptions = zBaseInputOptions.extend({
label_on: z.string().optional(),
label_off: z.string().optional(),
default: z.boolean().optional()
})
export const zStringInputOptions = zBaseInputOptions.extend({
default: z.string().optional(),
multiline: z.boolean().optional(),
dynamicPrompts: z.boolean().optional(),
// Multiline-only fields
defaultVal: z.string().optional(),
placeholder: z.string().optional()
})
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: resultItemType.optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
audio_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
const zFloatInputSpec = z.tuple([
z.literal('FLOAT'),
zFloatInputOptions.optional()
])
const zBooleanInputSpec = z.tuple([
z.literal('BOOLEAN'),
zBooleanInputOptions.optional()
])
const zStringInputSpec = z.tuple([
z.literal('STRING'),
zStringInputOptions.optional()
])
/**
* Legacy combo syntax.
* @deprecated Use `zComboInputSpecV2` instead.
*/
const zComboInputSpec = z.tuple([
z.array(zComboOption),
zComboInputOptions.optional()
])
const zComboInputSpecV2 = z.tuple([
z.literal('COMBO'),
zComboInputOptions.optional()
])
export function isComboInputSpecV1(
inputSpec: InputSpec
): inputSpec is ComboInputSpec {
return Array.isArray(inputSpec[0])
}
export function isIntInputSpec(
inputSpec: InputSpec
): inputSpec is IntInputSpec {
return inputSpec[0] === 'INT'
}
export function isFloatInputSpec(
inputSpec: InputSpec
): inputSpec is FloatInputSpec {
return inputSpec[0] === 'FLOAT'
}
export function isComboInputSpecV2(
inputSpec: InputSpec
): inputSpec is ComboInputSpecV2 {
return inputSpec[0] === 'COMBO'
}
export function isComboInputSpec(
inputSpec: InputSpec
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
}
/**
* Get the type of an input spec.
*
* @param inputSpec - The input spec to get the type of.
* @returns The type of the input spec.
*/
export function getInputSpecType(inputSpec: InputSpec): string {
return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
}
/**
* Get the combo options from a combo input spec.
*
* @param inputSpec - The input spec to get the combo options from.
* @returns The combo options.
*/
export function getComboSpecComboOptions(
inputSpec: ComboInputSpec | ComboInputSpecV2
): (number | string)[] {
return (
(isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
)
}
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
const zCustomInputSpec = z.tuple([
z.string().refine((value) => !excludedLiterals.has(value)),
zBaseInputOptions.optional()
])
const zInputSpec = z.union([
zIntInputSpec,
zFloatInputSpec,
zBooleanInputSpec,
zStringInputSpec,
zComboInputSpec,
zComboInputSpecV2,
zCustomInputSpec
])
const zComfyInputsSpec = z.object({
required: z.record(zInputSpec).optional(),
optional: z.record(zInputSpec).optional(),
// Frontend repo is not using it, but some custom nodes are using the
// hidden field to pass various values.
hidden: z.record(z.any()).optional()
})
const zComfyNodeDataType = z.string()
const zComfyComboOutput = z.array(zComboOption)
const zComfyOutputTypesSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
)
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
output_is_list: z.array(z.boolean()).optional(),
output_name: z.array(z.string()).optional(),
output_tooltips: z.array(z.string()).optional(),
output_matchtypes: z.array(z.string().optional()).optional(),
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
deprecated: z.boolean().optional(),
experimental: z.boolean().optional(),
/**
* Whether the node is an API node. Running API nodes requires login to
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
*/
api_node: z.boolean().optional(),
/**
* Specifies the order of inputs for each input category.
* Used to ensure consistent widget ordering regardless of JSON serialization.
* Keys are 'required', 'optional', etc., values are arrays of input names.
*/
input_order: z.record(z.array(z.string())).optional()
})
export const zAutogrowOptions = z.object({
...zBaseInputOptions.shape,
template: z.object({
input: zComfyInputsSpec,
names: z.array(z.string()).optional(),
max: z.number().optional(),
//Backend defines as mandatory with min 1, Frontend is more forgiving
min: z.number().optional(),
prefix: z.string().optional()
})
})
export const zDynamicComboInputSpec = z.tuple([
z.literal('COMFY_DYNAMICCOMBO_V3'),
zBaseInputOptions.extend({
options: z.array(
z.object({
inputs: zComfyInputsSpec,
key: z.string()
})
)
})
])
// `/object_info`
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
export type IntInputSpec = z.infer<typeof zIntInputSpec>
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
export type InputSpec = z.infer<typeof zInputSpec>
export function validateComfyNodeDef(
data: unknown,
onError: (error: string) => void = console.warn
): ComfyNodeDef | null {
const result = zComfyNodeDef.safeParse(data)
if (!result.success) {
const zodError = fromZodError(result.error)
onError(
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
)
return null
}
return result.data
}