Skip to content

Instantly share code, notes, and snippets.

@jordangarcia
Created April 15, 2026 02:35
Show Gist options
  • Select an option

  • Save jordangarcia/796604b3b5fedaed56a05d4ce4c7c36e to your computer and use it in GitHub Desktop.

Select an option

Save jordangarcia/796604b3b5fedaed56a05d4ce4c7c36e to your computer and use it in GitHub Desktop.

PR #19026 — Suggested simplification

The polymorphism win (no any casts, cleaner component API) can be achieved without the abstract class hierarchy. Here's the alternative.

Types — plain objects, no classes

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
  | ArtStyleSelect

Switch functions — keep as-is

resolveQuestionValue, 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.

Mutation helpers — new standalone functions

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 }
}

Hook — keep updateCurrentStep, use helpers internally

// 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])

Component call sites — no more any

// 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))}

What this keeps from the PR

  • updateCurrentStep replacing onFreeformChange + setFormState (the real API cleanup)
  • No any casts in ThemeOptions/ArtStyleOptions
  • Adding a new step type is still mechanical (add type, add switch cases, add helper if needed)

What this drops

  • Abstract class hierarchy (BaseStep + 5 subclasses)
  • clone() with Object.create(Object.getPrototypeOf(this))
  • declare readonly fields
  • Object.assign in constructors
  • Class instantiation at every call site (new TextSingleSelect({...}))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment