I just cleaned up a React billing form. The TypeScript types said one thing, the runtime said another — and the component was full of as casts and ?? "" fallbacks patching over the gap. This walks through the techniques that made the cleanup work.

The patient: app/javascript/components/billing/edit_address_modal.tsx and the shared app/javascript/types/billing.ts. You’ll recognize the field names (address, city, state, zip_code) in the demos below.

1. Record vs Partial<Record>

TypeScript has two common ways to type “an object keyed by these names.” They look similar. They behave very differently.

// loose: every key is optional, values can be undefined
type Loose = Partial<Record<"address" | "city", string>>

// strict: every key is required and always a string
type Strict = Record<"address" | "city", string>

What to notice: the billing form used Partial<Record> but always populated every key on initialization. That mismatch meant every fields[name] read returned string | undefined, forcing fallbacks that never actually ran. Switching to Record matched the invariant.

fields["address"]
type: string
value: "123 Main St"

Always a string. Safe to call .toUpperCase() or .join on an errors array.

Keys in both types: addresscitystatezip_code

2. Enums as keys instead of string

The old handleFieldChange took name: string. Inside, it had to keep casting back: formErrors[name as FormFieldName]. Three casts in one short function.

enum FormFieldName {
  Address = "address",
  City = "city",
  State = "state",
  ZipCode = "zip_code",
}

// before
function handleFieldChange({ name }: { name: string }) {
  delete formErrors[name as FormFieldName]  // cast
}

// after
function handleFieldChange({ name }: { name: FormFieldName }) {
  delete formErrors[name]  // no cast
}

What to notice: when the type of name matches the type of the object’s keys, indexing Just Works. The cast was a smell pointing at the real fix.

formErrors["address" as FormFieldName] → ✓ works at runtime
formErrors[FormFieldName.Address] → ✓ typechecks, works

Try typing addres (one s) or zip. The strict typing catches the typo at compile time. The loose string typing only notices at runtime — if at all.

3. Object.fromEntries beats reduce here

The original built initialFields with a reduce, spreading the accumulator on every step. That’s O(n²) in allocation and reads awkwardly. For this exact shape — array of keys → object — Object.fromEntries is cleaner and linear.

// before
const initialFields = Object.values(FormFieldName).reduce(
  (fields, name) => ({ ...fields, [name]: organization[name] }),
  {}
)

// after
const initialFields = Object.fromEntries(
  Object.values(FormFieldName).map(name => [name, organization[name] ?? ""])
)

What to notice: the intent is literally “pair each name with its value, then make an object.” fromEntries says that; reduce hides it.

step 0: collect [key, value] pair
entries so far: []

Map to pairs, then one call builds the object. The shape of the code matches the shape of the data.

4. Generic useState over a cast

// before
const [formErrors, setFormErrors] = useState({} as FormErrorsState)

// after
const [formErrors, setFormErrors] = useState<FormErrorsState>({})

What to notice: {} as T tells TypeScript “trust me, this empty object is a T.” That works by accident — an empty object happens to be assignable to Partial<Record> — but it’s a lie in general. useState<T> says “the state is a T; the initial value must conform.” No trust required.

(No interactive demo — the snippet above is the core idea.)

5. A helper to kill repeated prop spreading

Four inputs, each needing the same two error-display props. That’s eight lines of duplication and two places per field to forget a change. A tiny helper collapses it.

function errorProps(field: FormFieldName) {
  const error = formErrors[field]
  return { description: error?.join(", "), invalid: Boolean(error) }
}

// usage
<Input {...errorProps(FormFieldName.Address)} />
<Input {...errorProps(FormFieldName.City)} />

What to notice: the helper captures a mapping — field name to display props — not a generic abstraction. One source of truth for how an error shows.

  <Input {...errorProps(FormFieldName.Address)} />
  <Input {...errorProps(FormFieldName.City)} />
  <Input {...errorProps(FormFieldName.State)} />
  <Input {...errorProps(FormFieldName.ZipCode)} />

Four lines, one thing to read. Change how errors render? One edit to errorProps.

6. Optional chaining + nullish coalescing

With tighter types, you still need to handle “no error for this field yet.” error?.join(", ") returns undefined if error is missing. Boolean(error) turns it into a clean true/false. And for values that might be undefined, ?? "" gives a safe default.

error: undefined
error?.join(", "): undefined
Boolean(error): false
Address
123 Main St

Slide from 0 → 3. At 0, error is undefined; optional chaining short-circuits and no error UI renders. At 1+, the array joins into a single string and invalid becomes true.

Where to go next

The same moves apply elsewhere. When you see a type-alias that’s just string, delete it. When you see as SomeType scattered around, check whether the source type should be tighter. When you see a reduce building an object from an array of keys, reach for Object.fromEntries.