skip to Main Content

I have a relatively generic component. Its definition looks something like the following:

export type CheckboxItem = {
  label: string,
  code: string,
};

export type CheckboxesProps = {
  items: CheckboxItem[],
  handleStateChange: (selected: (CheckboxItem['code'])[]) => void,

};

export interface CheckboxState<T>  {
  [key: CheckboxItem['code']]: boolean,
}


export default function Checkboxes({items, handleStateChange}: CheckboxesProps) {
    const [state, setState] = useState<CheckboxState>(items.reduce((stateObj: CheckboxState, item: CheckboxItem) => {
      stateObj[item.code] = false;
      return stateObj;
    }, {}));

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
      setState({
        ...state,
        [event.target.name]: event.target.checked,
      });
      handleStateChange(Object.keys(state).filter(key => state[key]));
  };
...
}

This works until I try invoking Checkboxes with items that are Object Constants (not sure correct typescript terminology):

// within another component
export const PrimaryNeeds = {
  foodAllergies: 'foodAllergies',
  foodRestrictions: 'foodRestrictions',
  medicineAllergies: 'medicineAllergies',
  medicalConditions: 'medicalConditions',
  phobias: 'phobias',
  other: 'other',
} as const;

// type checkboxItems = {
//    label: PrimaryNeeds[keyof PrimaryNeeds]
//    code: keyof PrimaryNeeds, 
// }
// The above type isn't actually in my code, I simply do the following instead:

export type PrimaryNeedsCheckboxesProps = {
  langCode: keyof typeof LanguageCodes,
  handlePrimaryNeedsChanged: (selected: (keyof typeof PrimaryNeeds)[]) => void,
}

export default function PrimaryNeedsCheckboxes({langCode, handlePrimaryNeedsChanged}: PrimaryNeedsCheckboxesProps) {

   const checkboxItems = (Object.keys(PrimaryNeeds) as (keyof PrimaryNeeds)[]).map((needKey: keyof PrimaryNeeds) => {
        return {
          label: primaryNeeds[needKey],
          code: needKey,
        };
      });
    // ...
   return (
     <Checkboxes items={checkboxItems} handleStateChange={handlePrimaryNeedsChanged} />
   )

Now I get an error about type mismatch between the type string not being assignable to the I guess "union type" of the PrimaryNeeds keys:

Type '(selected: ("foodAllergies" | "foodRestrictions" | "medicineAllergies" | "medicalConditions" | "phobias" | "other")[]) => void' is not assignable to type '(selected: string[]) => void'.
  Types of parameters 'selected' and 'selected' are incompatible.
    Type 'string[]' is not assignable to type '("foodAllergies" | "foodRestrictions" | "medicineAllergies" | "medicalConditions" | "phobias" | "other")[]'.
      Type 'string' is not assignable to type '"foodAllergies" | "foodRestrictions" | "medicineAllergies" | "medicalConditions" | "phobias" | "other"'.

I tried to set a generic type so that the Checkbox component CheckboxItem object property types could depend on a passed-in type, but then I ran into typescript not allowing generic types to be used as index:

export type CheckboxItem<T> = {
  label: keyof T,
  code: T[keyof T],
};
export type CheckboxesProps<T>= {
  items: CheckboxItem<T>[],
  handleStateChange: (selected: (CheckboxItem<T>['code'])[]) => void,
};
// error points to "key" below
export interface CheckboxState<T>  {
  [key: CheckboxItem<T>['code']]: boolean,
}

An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.

I read the mapped object documentation but didn’t see how that could help, though I don’t exactly understand the mechanism of how they’re supposed to help either. Nor do I understand the issue around using a generic type as an index signature parameter – it seems to be what I want, though I guess there’s a good reason.

I did try using the bit recommended in Key Remapping:

export interface CheckboxState<T>  {
  [K in T]?: boolean,
}

But Typescript didn’t like that either:

A mapped type may not declare properties or methods.

How can I create a generic type for these props? Or, is that what I should even be doing here?

2

Answers


  1. type and interface in TypeScript are mostly equivalent, but mapped types are an exception – they have to be types.

    So

    export interface CheckboxState<T> {
      [key in keyof T]: boolean
    }
    

    throws an error, but

    export type CheckboxState<T> = {
      [key in keyof T]: boolean
    }
    

    works.

    (It also looks like your example code may be confusing codes and labels – your commented-out checkboxItems uses code as the keys and label as the values (I assume this is what you need), but your CheckboxItems generic type uses label as the keys and code as the value.)

    Login or Signup to reply.
  2. I suppose you could start with a useCheckboxes hook –

    type CheckboxesState = Record<string, boolean>
    
    type UseCheckboxes<T extends CheckboxesState> = {
        keys: Array<keyof T>,
        get: (key: keyof T) => boolean,
        set: (key: keyof T, value: boolean) => void,
    }
    
    function useCheckboxes<T extends CheckboxesState>(init: (() => T) | T): UseCheckboxes<T> {
        const [state, setState] = useState(init)
        return useMemo(
            () => ({
                keys: Object.keys(state),
                get: (key) => state[key],
                set: (key, value) => { setState(s => ({ ...s, [key]: value })) },
            }),
            [state],
        )
    }
    

    Each instance of useCheckboxes returns a unique type –

    const mydata = useCheckboxes({
        turbo: true,
        debug: false,
    })
    
    // mydata : UseCheckboxes<{ turbo: true, debug: false }>
    

    We get helpful type auto-completions when using the set and get methods –

    1

    Now you can write your Checkboxes component. UseCheckboxes gives us everything we need to iterate and display the checkboxes, and create an onChange handler for each checkbox –

    function Checkboxes<T extends CheckboxesState>(props: { items: UseCheckboxes<T> }) {
        const { items } = props
        return (
            <div>
                {items.keys.map(key => (
                    <label key={key}>
                      <input
                        type="checkbox"
                        checked={items.get(key)}
                        value={key}
                        onChange={() => items.set(key, !items.get(key))}
                      />
                      {key}
                    </label>
                ))}
            </div>
        )
    }
    

    Here’s a complete demo you can run in your browser –

    const { useMemo, useState } = React
    
    function useCheckboxes(init) {
        const [state, setState] = React.useState(init);
        return React.useMemo(() => ({
            keys: Object.keys(state),
            get: (key) => state[key],
            set: (key, value) => { setState(s => (Object.assign(Object.assign({}, s), { [key]: value }))); },
        }), [state]);
    }
    
    function Checkboxes(props) {
        const { items } = props
        return (
            <div>
                {items.keys.map(key => (
                    <label key={key}>
                      <input
                        type="checkbox"
                        checked={items.get(key)}
                        value={key}
                        onChange={() => items.set(key, !items.get(key))}
                      />
                      {key}
                    </label>
                ))}
            </div>
        )
    }
    
    function App() {
        const checkboxes = useCheckboxes({
            foo: true,
            bar: false,
        });
        return <Checkboxes items={checkboxes} />
    }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(<App />)
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search