Skip to content

Conversation

RobinMalfait
Copy link
Member

This PR fixes an issue where the types for certain event handlers such as onChange on the Combobox component were incorrectly typed as bivariant instead of contravariant.

This is now fixed by using function property syntax instead of method syntax.

Before:

function onChange(value: string) {}

// This would have worked, even though the internal types are `(value: string | null) => void`
return <Combobox onChange={onChange} />;

After:

function onChange(value: string) {}

// This now errors because we expect `string | null`
return <Combobox onChange={onChange} />;

In both cases:

function onChange(value: string | null) {}

// This is the correct type, and works in both cases
return <Combobox onChange={onChange} />;

A pure TypeScript example, thanks to @mn-prp: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBMCGAbRB1AllAFgWQKZZABMYBeGACgAcAnESiALhgG8ZwBhTeMAc13IBuSAK64mYYchgAfGNGppeASiYCQaYgF8lpAHwtNAKEOhIsBMnRYACrUq5qUAJ6kYFGnUYs2YTtz5MgiJiMBJSsvKKPDok+moaMNp6Bsam0HBIiABG8MAA1q5BiKJMkcrJQsW4qeDp8NQ8rqwcXLwhFtm5BUYmmVY4+JhE5PXRvZYYmLZ0Ds4jDUpAA


The onChange was the important one, but fixed the other spots where we use method syntax as well.

Fixes: #3727

Copy link

vercel bot commented Aug 29, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
headlessui-react Ready Ready Preview Comment Aug 29, 2025 6:48pm
headlessui-vue Ready Ready Preview Comment Aug 29, 2025 6:48pm

} | null

onClose?(): void
onClose?: () => void
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary in this case, but more consistent

Copy link
Contributor

@thecrypticace thecrypticace left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wild to me that TypeScript works this way but I guess they had to preserve some behavior for backwards compatibility which is why methods in objects act differently than function properties

@RobinMalfait RobinMalfait merged commit 87b3ffc into main Aug 29, 2025
8 checks passed
@RobinMalfait RobinMalfait deleted the fix/issue-3727 branch August 29, 2025 19:02
@baraknaveh
Copy link

Note: I believe that his change broke compilation for me, despite being included in a patch version:

Type '((value: number) => void) | undefined' is not assignable to type '((value: number | null) => void) | undefined'.
  Type '(value: number) => void' is not assignable to type '(value: number | null) => void'.
    Types of parameters 'value' and 'value' are incompatible.
      Type 'number | null' is not assignable to type 'number'.
        Type 'null' is not assignable to type 'number'.

---
Type '(newSelectedOption: string) => void' is not assignable to type '(value: string | null) => void'.
  Types of parameters 'newSelectedOption' and 'value' are incompatible.
    Type 'string | null' is not assignable to type 'string'.
      Type 'null' is not assignable to type 'string'.

@RobinMalfait
Copy link
Member Author

@baraknaveh this is a trade-off I was willing to make. This change didn't introduce a bug/issue but it exposed one. We've always called the onChange with null in some cases and if your own code assumes it's always a string then you would have runtime bugs. Now we changed this from a runtime bug to a compile-time bug instead.

If you look at an example like this. Then the type would be string | null.

<Combobox onChange={ x => {
              //     ^?  string | null
} } />

However, if you don't inline the function and pass in a function instead that doesn't adhere to this signature, then TypeScript was also fine with it:

function handleChange(x: string) {
  x.length // TypeScript is fine with it, but it would crash at runtime
}

<Combobox onChange={handleChange} />

We now ensure that the type of that handleChange function must match what we expect. In this case it should be:

- function handleChange(x: string) {
+ function handleChange(x: string | null) {
    x.length // TypeScript is fine with it, but it would crash at runtime
  }

So while this change can trigger compile time errors now, I believe this is a much better spot to expose bugs than at runtime.

This is unfortunately one of those cases where every bugfix is a breaking change if you rely on the bug's behavior.

But hopefully with this explanation, it makes sense why it's part of a patch release (because the bug was always there at runtime).

@clemens
Copy link

clemens commented Sep 16, 2025

We're also getting an error after upgrading to v2.2.8. Here's the rough setup:

We have a component that wraps a combobox:

type Option = { id: string; name: string }

type Props = {
  options: Option[]
  label?: string
  onChange?: (option: Option) => void
  value?: Option
  active?: boolean
}

export const DropdownWithSearch: FC<Props> = ({
  options,
  label,
  value,
  onChange,
  active = true,
}) => {
  // …

  return (
    <Combobox value={filteredOptions.find(({ id }) => selected.id === id)} onChange={onChange}>
      All the other stuff is here
    </Combobox>
  )
}

There's also a preview/test for this that almost directly follows what's shown in the docs:

import { type FC, useState } from "react"

import { DropdownWithSearch } from "."

const options = [
  {
    id: "1",
    name: "Option 1",
  },
  {
    id: "3",
    name: "Optione 2",
  },
  {
    id: "2",
    name: "Optionen 3",
  },
]

export const DropdownWithSearchPreview: FC = () => {
  const [selected, setSelected] = useState(options[0])
  return (
    <div className="flex items-end gap-x-8 bg-white px-6 py-2">
      <DropdownWithSearch
        value={selected}
        onChange={setSelected}
        options={options}
        label="Label for"
      />
      <DropdownWithSearch
        value={selected}
        onChange={setSelected}
        options={options}
        label="Label for inactive"
        active={false}
      />
    </div>
  )
}

Before the change, it worked; in v2.2.8, I'm getting a type error for onChange:

Type '((option: Option) => void) | undefined' is not assignable to type '((value: NoInfer<{ name: string; id: string; }> | null) => void) | undefined'.
  Type '(option: Option) => void' is not assignable to type '(value: NoInfer<{ name: string; id: string; }> | null) => void'.
    Types of parameters 'option' and 'value' are incompatible.
      Type 'NoInfer<{ name: string; id: string; }> | null' is not assignable to type 'Option'.
        Type 'null' is not assignable to type 'Option'.ts(2322)
combobox.d.ts(21, 5): The expected type comes from property 'onChange' which is declared here on type 'IntrinsicAttributes & CleanProps<ExoticComponent<{ children?: ReactNode; }>, "form" | "value" | "onChange" | "defaultValue" | "multiple" | ... 8 more ... | "__demoMode"> & OurProps<...> & { ...; } & { ...; }'

When I change the type like so:

type Props = {
  options: Option[]
  label?: string
  onChange?: (option: Option | null) => void // <= changed `Option` to `Option | null`
  value?: Option
  active?: boolean
}

Then the type error in the component's onChange goes away, but I'm getting one in the preview instead:

Type 'Dispatch<SetStateAction<{ id: string; name: string; }>>' is not assignable to type '(option: Option | null) => void'.
  Types of parameters 'value' and 'option' are incompatible.
    Type 'Option | null' is not assignable to type 'SetStateAction<{ id: string; name: string; }>'.
      Type 'null' is not assignable to type 'SetStateAction<{ id: string; name: string; }>'.ts(2322)
index.tsx(12, 3): The expected type comes from property 'onChange' which is declared here on type 'IntrinsicAttributes & Props'

Did I read something wrong and the | null change is not what's needed here?

@RobinMalfait
Copy link
Member Author

That is correct, the missing piece is this line:

const [selected, setSelected] = useState(options[0])

This is now typed as useState<typeof options[0]> (it's inferred as the type of the option), but it can also be null, so you have to be explicit useState<typeof options[0] | null>.

The fact that it can be null is not something that changed as part of this PR. This has always been the case since Headless UI v2.

If you don't want it to be null, then you can technically do this:

- onChange={setSelected}
+ onChange={value => {
+   if (value) {
+     setSelected(value)
+   }
+ }}

This guarantees that the selected will never be null. But this could result in other issues because now you can't "unset" the Combobox value anymore.

A use case where we call the onChange with null is when you remove all the text from the ComboboxInput.

@clemens
Copy link

clemens commented Sep 16, 2025

Thanks for explaining, Robin! That indeed solved it, although I also had to change the type of value as a result, since it was using selected:

type Props = {
  options: Option[]
  label?: string
  onChange?: (option: Option | null) => void
  value?: Option | null // <= this can't just be `Option` any longer
  active?: boolean
}

Out of curiosity: Where would I find such a thing in the docs? I can't seem to find docs regarding TypeScript…

@baraknaveh
Copy link

@RobinMalfait not challenging the usefulness of the change. I just flagged that it's compilation breaking, thus by semver convention, requires a major version bump (ie., it's not a patch version).

@dd-jonas
Copy link

@RobinMalfait What is the recommended way to create non nullable inputs now? In my case, I have a Listbox with controlled non-nullable state, but the listbox won't accept my function handleChange(x: string) {} function due to contravariance.

@RobinMalfait
Copy link
Member Author

@clemens we don't have TypeScript related docs on its own, but you should be able to get all the information from the components in your editor. We also talked about the nullable by default change when we introduced v2: https://github.com/tailwindlabs/headlessui/releases/tag/%40headlessui%2Freact%40v2.0.0


@baraknaveh Since the bug was always there, we considered it a bug fix, because it actually fixes a (runtime) bug. Like I mentioned earlier, every bug fix can be a breaking change if you relied on the bug.


@dd-jonas In Headless UI v2 we've always had nullable Comboboxes, this PR didn't change that. It just fixed the types to be more correct. Even with the code you had before this PR, the handleChange would be called with null in some scenario's.

In your case, you can use:

function handleChange (x: string | null) {
  if (x === null) return // Just ignore `null`, or handle `null` however you want
  
  // Proceed as before
}

IDisposable added a commit to IDisposable/kvm that referenced this pull request Sep 23, 2025
HeadlessUI changed the type declaration of Combobox onChange
tailwindlabs/headlessui#3781
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Combobox types are bivariant instead of contravariant
5 participants