The polymorphism win (no any casts, cleaner component API) can be achieved without the abstract class hierarchy. Here's the alternative.
Keep the original discriminated union types unchanged:
type BaseQuestion = {
label: string
question: string
auto?: boolean
visible?: (formState: Partial<Record<string, QuestionState>>) => boolean
}
type TextSingleSelect = BaseQuestion & {
type: 'text'
multiSelect: false
options: TextOption[]
selected: number | null
freeform?: TextFreeform
}
type TextMultiSelect = BaseQuestion & {
type: 'text'
multiSelect: true
options: TextOption[]
selected: number[]
freeform?: TextFreeform
}
type NumberSelect = BaseQuestion & {
type: 'number'
multiSelect: false
options: Array<{ label: string; value: number }>
selected: number | null
freeform?: NumberFreeform
}
type ThemeSelect = BaseQuestion & {
type: 'theme'
multiSelect: false
options: Theme[]
selected: number | null
}
type ArtStyleSelect = BaseQuestion & {
type: 'artStyle'
multiSelect: false
options: ArtStyleOption[]
selected: number | null
}
type QuestionState =
| TextSingleSelect
| TextMultiSelect
| NumberSelect
| ThemeSelect
| ArtStyleSelectresolveQuestionValue, hasValidSelection, resolveDisplayValue stay as they were.
TypeScript's exhaustive switch narrowing already gives type safety — if you add a
sixth variant and forget a case, the return type won't be satisfied.
These replace the any-cast state surgery that was in ThemeOptions/ArtStyleOptions,
and the inline mutation logic that was in useMiniformState:
function handleSelect(step: QuestionState, index: number): QuestionState {
if (step.type === 'text' && step.multiSelect) {
const selected = step.selected.includes(index)
? step.selected.filter((i) => i !== index)
: [...step.selected, index]
return { ...step, selected }
}
return { ...step, selected: index }
}
function handleFreeformChange(
step: QuestionState,
value: string | number,
): QuestionState {
if (step.type === 'text') {
if (step.multiSelect) {
const selected = value
? step.selected.includes(FREEFORM_INDEX)
? step.selected
: [...step.selected, FREEFORM_INDEX]
: step.selected.filter((i) => i !== FREEFORM_INDEX)
return {
...step,
selected,
freeform: step.freeform && { ...step.freeform, current: value as string },
}
}
return {
...step,
selected: FREEFORM_INDEX,
freeform: step.freeform && { ...step.freeform, current: value as string },
}
}
if (step.type === 'number' && typeof value === 'number') {
return {
...step,
selected: FREEFORM_INDEX,
freeform: step.freeform && { ...step.freeform, current: value },
}
}
return step
}
function selectOrReplaceLastTheme(step: ThemeSelect, theme: Theme): ThemeSelect {
const existing = step.options.findIndex((t) => t.id === theme.id)
if (existing >= 0) return { ...step, selected: existing }
const options = [...step.options.slice(0, -1), theme]
return { ...step, options, selected: options.length - 1 }
}
function selectOrReplaceLastArtStyle(
step: ArtStyleSelect,
option: ArtStyleOption,
): ArtStyleSelect {
const existing = step.options.findIndex((o) => o.key === option.key)
if (existing >= 0) return { ...step, selected: existing }
const options = [...step.options.slice(0, -1), option]
return { ...step, options, selected: options.length - 1 }
}// handleSelectOption uses the helper:
const nextFormState = {
...prevFormState,
[currentStep]: handleSelect(prevStep, index),
}
// updateCurrentStep stays as the PR has it:
const updateCurrentStep = useCallback(
(updated: QuestionState) => {
if (currentStep === REVIEW_STEP) return
setFormState((prev) => {
const current = prev[currentStep]
return updated === current ? prev : { ...prev, [currentStep]: updated }
})
},
[currentStep],
)
// canSubmitCurrentStep uses the standalone function:
const canSubmitCurrentStep =
currentStep === REVIEW_STEP
? true
: hasValidSelection(formState[currentStep])// ThemeOptions.tsx — was: setFormState((prev: any) => { ... 15 lines of any-cast surgery })
updateStep(selectOrReplaceLastTheme(stepState, theme))
// ArtStyleOptions.tsx — same pattern
updateStep(selectOrReplaceLastArtStyle(stepState, buildArtStyleOption(key, ref)))
// TextOptions.tsx
onChange={(v) => updateStep(handleFreeformChange(stepState, v))}
// NumberOptions.tsx
onChange={(v) => updateStep(handleFreeformChange(stepState, v))}updateCurrentStepreplacingonFreeformChange+setFormState(the real API cleanup)- No
anycasts in ThemeOptions/ArtStyleOptions - Adding a new step type is still mechanical (add type, add switch cases, add helper if needed)
- Abstract class hierarchy (
BaseStep+ 5 subclasses) clone()withObject.create(Object.getPrototypeOf(this))declare readonlyfieldsObject.assignin constructors- Class instantiation at every call site (
new TextSingleSelect({...}))