Skip to main content

3 posts tagged with "open source software"

View All Tags

Calling Mandatory Getter props

· 12 min read

hilly landscape in the village of simon, brasov Hilly Landscape in the Village of Şimon.

...there's a lot going on with the attributes and the handlers and the ref and the other properties...

Let's assume that we have the following scenario. We have a custom hook that returns getter props, and we want to make sure that those getter props are called correctly. But, first things first:

What is a getter prop?

It's a function that gets returned from the custom hook, which is supposed to be called and its result used for some reason. Let's take the getInputProps getter prop from downshift:

import * as React from 'react'
import {useCombobox} from 'downshift'

const items = ['red', 'green', 'blue']

const {getInputProps} = useCombobox({
items,
})

return (
<div>
<input {...getInputProps()} />
</div>
)

The getInputProps result is an object with properties such as: aria-labelledby, aria-expanded, role, onChange, onClick etc. These properties are going to make our input more or less acting like a combobox. The combobox logic is kept inside the custom hook, the inputs are retrieved through the event handler functions (onClick, onChange) and the combobox related information is passed through the other attributes (aria-labelledby, aria-expanded). Of course, there are many more handlers and properties, but we are keeping it simple for now.

There's also the ref property that is going to be returned from getInputProps, and this is needed in order to allow useCombobox to access the input element and perform some custom .focus() actions when needed.

The Problem

Given that there's a lot going on with the attributes and the handlers and the ref and the other properties, we want to make sure that the getInputProps getter function is called correctly.

  • First of all, we want to make sure that it is called.
  • Secondly, that is called on an <input> element, or in such a way that it has access to an <input> element.
  • Thirdly, in case it is not called correctly, we want to inform the user through a console error and give them enough information to fix the getter function call.
  • Moreover, we also want to show the console error in the development environment, not production. -In addition, we would like to show this error only on the first render, as we believe it's enough in terms of checks and informations.
  • Last, but not least, we will also provide an escape hatch to avoid getting this error if the consumer just cannot call getInputProps properly on the first render.
    • We are going to document this escape hatch and make sure the consumer knows why we are adding it. Ideally, if a consumer will use the escape hatch, they are aware how getInputProps works, how it should be called, and if they, for some reason, cannot call it on first render (using a Portal for example), they will use the escape hatch to avoid the error, but they will test their implementation to make sure that it works as it would by calling getInputProps normally.

The Solution

Inside the getInputProps function we return, we would like to have a mechanism that checks whether the function is called withing the right conditions. We can do so with a function to which we supply the call parameters. This function will be called at render time, since getInputProps is called on render time. The actual check happens after the first render, so we will involve a React.useEffect hook. After a bit of thinking, the solution is actually simple: a custom React hook that will keep call information inside a React ref. This call information will be supplied by a function which we will return from the hook. The returned function will store the call information inside that ref. And inside the effect function, we will perform the check and show the errors if needed.

The Tests

We actually have a good and simple solution in mind, so let's see what do we actually expect from this solution.

  • we want an error displayed when getInputProps is not called.
  • we want an error displayed if getInputProps is not called with a ref pointing to an element.
  • we don't want an error displayed if getInputProps is not called on subsequent renders.
  • we don't want an error displayed if getInputProps is called correctly.
  • we don't want an error displayed if we use the escape hatch provided and getInputProps is not called with a ref pointing to an element.

We will use React Testing Library renderHook and together with Jest, we will set up our tests suite.

describe('non production errors', () => {
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})

afterAll(() => {
jest.restoreAllMocks()
})

test('will be displayed if getInputProps is not called', () => {
renderHook(() => {
// we just render the hook, we don't call getInputProps.
useCombobox({items})
})

expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(
`downshift: You forgot to call the getInputProps getter function on your component / element.`,
)
})

test('will be displayed if element ref is not set and suppressRefError is false', () => {
renderHook(() => {
const {getInputProps} = useCombobox({
items,
})

getInputProps()
})

expect(console.error.mock.calls[0][0]).toMatchInlineSnapshot(
`downshift: The ref prop "ref" from getInputProps was not applied correctly on your element.`,
)
})

test('will not be displayed if getInputProps is not called on subsequent renders', () => {
let firstRender = true
const {rerender} = renderHook(() => {
const {getInputProps} = useCombobox({
items,
})

if (firstRender) {
firstRender = false
getInputProps({}, {suppressRefError: true})
}
})

rerender()

expect(console.error).not.toHaveBeenCalled()
})

test('will not be displayed if called with a correct ref', () => {
// we supply a mock ref function to getInputProps.
const refFn = jest.fn()
const inputNode = {}

renderHook(() => {
const {getInputProps} = useCombobox({
items,
})

// getInputProps returns a ref function which will make the element
// usable both outside and inside useCombobox.
const {ref} = getInputProps({
ref: refFn,
})

// we call the final ref function received with a dummy node.
ref(inputNode)
})

expect(console.error).not.toHaveBeenCalled()
})

test('will not be displayed if element ref is not set and suppressRefError is true', () => {
renderHook(() => {
const {getInputProps} = useCombobox({
items,
})

getInputProps({}, {suppressRefError: true})
})

expect(console.error).not.toHaveBeenCalled()
})
})

The tests are quite self explanatory given our 5 expected use cases. For now, they will obviously fail, and we will consider our job done once we write the implementation and the tests are green.

The Implementation

To recap, the getter prop is called on render, so we would like our check to be performed after the render. We will store the call information inside a React ref, and we will check it using a React useEffect. Since we need our check to be performed in development mode, not production, we will define the custom hook like this:

let useGetterPropsCalledChecker = () => {}

if (process.env.NODE_ENV !== 'production') {
useGetterPropsCalledChecker = () => {
// the actual implementation.
}
}

First and foremost, we would like to set up our ref object that we will use for the check.

if (process.env.NODE_ENV !== 'production') {
useGetterPropsCalledChecker = (...propKeys) => {
const getterPropsCalledRef = useRef(
propKeys.reduce((acc, propKey) => {
acc[propKey] = {}

return acc
}, {}),
)
}
}

And we will call this hook inside useCombobox like this:

useGetterPropsCalledChecker(
'getInputProps',
'getToggleButtonProps',
'getMenuProps',
)

Notice that we will most definitely want the hook to check that other getter props are called correctly as well. We are going to stick to getInputProps here purely for explanatory purposes. Anyway, for each prop key we pass to the hook, we will create an empty object container inside the ref. We add this empty object as we want, during our checking phase, to be able to tell if the getter prop with that prop name was not called. We cannot perform this check if we do not store any initial information about the getter props. What prop keys should we actually check, right?

Next, we want to give the consumer, which is the getter prop function, to be able to store the call information. For that, we will return a function from the hook, which stores this information within the ref.

if (process.env.NODE_ENV !== 'production') {
useGetterPropsCalledChecker = (...propKeys) => {
const getterPropsCalledRef = useRef(
propKeys.reduce((acc, propKey) => {
acc[propKey] = {}

return acc
}, {}),
)
}

const setGetterPropCallInfo = useCallback(
(propKey, suppressRefError, refKey, elementRef) => {
getterPropsCalledRef.current[propKey] = {
suppressRefError,
refKey,
elementRef,
}
},
[],
)

return setGetterPropCallInfo
}

And the usage within getInputProps is going to be something like this:

// this is going to end up on the input element via getInputProps
const inputRef = React.useRef(null)

const getInputProps = useCallback(
({refKey = 'ref', ref, ...rest} = {}, {suppressRefError = false} = {}) => {
setGetterPropCallInfo('getInputProps', suppressRefError, refKey, inputRef)

// rest of the logic

return {
[refKey]: mergeRefsUtil(ref, inputNode => {
inputRef.current = inputNode
}),
// the rest of the attributes and handlers that will end on the input
}
},
)

In case the above ref logic is not clear, it ensures that all ref objects have access to the same input element. We need the input ref in useCombobox. The consumer might also need the input ref for some additional logic, hence the merge of these ref objects and passing the merged object to the <input> element. Also, suppressRefError is the escape hatch we talked about previously.

Now the checking part. We have the initial object created for each getter prop on render phase, when useGetterPropsCalledChecker is called. We have the call information stored when getInputProps is called, also on render phase. Now we have to check if the function is called correctly, on first render only, so we add a React useEffect hook inside our custom hook:

useEffect(() => {
Object.keys(getterPropsCalledRef.current).forEach(propKey => {
const propCallInfo = getterPropsCalledRef.current[propKey]

if (!Object.keys(propCallInfo).length) {
// eslint-disable-next-line no-console
console.error(
`downshift: You forgot to call the ${propKey} getter function on your component / element.`,
)
return
}
})
})

Our first test should pass. If the object is empty, then it means that we did not call the getter prop at all, so we show an error. If the getter prop is actually called, then we need to check if the element exists, which means that the getter prop is called correctly on an element.

useEffect(() => {
Object.keys(getterPropsCalledRef.current).forEach(propKey => {
const propCallInfo = getterPropsCalledRef.current[propKey]

if (!Object.keys(propCallInfo).length) {
// eslint-disable-next-line no-console
console.error(
`downshift: You forgot to call the ${propKey} getter function on your component / element.`,
)
return
}

const {refKey, elementRef} = propCallInfo

if (!elementRef?.current) {
// eslint-disable-next-line no-console
console.error(
`downshift: The ref prop "${refKey}" from ${propKey} was not applied correctly on your element.`,
)
}
})
})

Our second test should now pass. The element check is, sure, not ideal, since we're not checking for an actual HTML input element, but let's say it suffices for now. The fourth test which checks that there is no error logged was passing already, but now with the actual element check, we make sure that, when it's passing, it does so for the right reason.

To fix the second test, we need our hook to be called only once, on first render, so we add [] as the second parameter to useEffect.

And to fix the fifth test, which checks the escape hatch, we will also involve the suppressRefError parameter from the call information. Our final implementation will look like this.

if (process.env.NODE_ENV !== 'production') {
useGetterPropsCalledChecker = (...propKeys) => {
const getterPropsCalledRef = useRef(
propKeys.reduce((acc, propKey) => {
acc[propKey] = {}

return acc
}, {}),
)

useEffect(() => {
Object.keys(getterPropsCalledRef.current).forEach(propKey => {
const propCallInfo = getterPropsCalledRef.current[propKey]

if (!Object.keys(propCallInfo).length) {
// eslint-disable-next-line no-console
console.error(
`downshift: You forgot to call the ${propKey} getter function on your component / element.`,
)
return
}

const {suppressRefError, refKey, elementRef} = propCallInfo

if (suppressRefError) {
return
}

if (!elementRef?.current) {
// eslint-disable-next-line no-console
console.error(
`downshift: The ref prop "${refKey}" from ${propKey} was not applied correctly on your element.`,
)
}
})
}, [])

const setGetterPropCallInfo = useCallback(
(propKey, suppressRefError, refKey, elementRef) => {
getterPropsCalledRef.current[propKey] = {
suppressRefError,
refKey,
elementRef,
}
},
[],
)

return setGetterPropCallInfo
}
}

Recap

Basically, we initialize an empty object for each getter prop we want to check, using the getterPropsCalledRef React ref object. Then we return the setGetterPropCallInfo function that will store information for each getter function when called. Finally, inside the useEffect function, for each getter prop, we get the call info from the ref, we check for an empty object and show the getter function not getting called error. In case we our object in not empty, we get the call information, and do nothing if suppressRefError is true. Otherwise, we check the actual element ref and show an error if it's falsy.

We may go even further with the tests and check if the hook is actually an empty object on production environments, but at this point I'm not sure if it's possible to implement such a test, or if it's even worth it. Bottom line is that, using this custom hook, we are able to perform the function getting called check, or other checks as well, in order to make sure our consumers are doing the right thing and the solution we ship works for them.

TDD a Downshift feature request

· 12 min read

praia da arrifana, portugal Praia da Arrifana, Portugal.

I don't always do TDD, but when I do, I write about it.

From the GitHub issue to the next library update on npm, we are going to push a feature request via the magic of Test Driven Development. It's easier than you think, and next time when the interviewer asks if you've heard about TDD, you will smile, say yes, then test drive their Leetcode puzzle and bring home the biggest offer they could make. TDD is also great in day-to-day work when adding features for your products, fixing issues and solving open source tickets, which is what we're going to do here.

The Problem

Let's take the following small feature request from Downshift:

Select element doesn't receive focus when the label is clicked.

Test driving this feature means that we need to write the tests before the feature. And that's a good thing, because writing the tests first will make us ask the most relevant question, what exactly do I want to achieve here.

That usually means drafting a list of requirements, and based on that list of requirements, we will write the tests. Running the tests will result in test failures (at least they should) and by implementing the feature the tests will start turning green. Once all the tests are green, it means our feature is complete. This is called red-green refactoring.

Before making the list of requirements, let's also visualise how this select element is built with Downshift's useSelect.

const {getToggleButtonProps, getLabelProps, getMenuProps, isOpen, ...rest} =
useSelect({
items,
})

return (
<div>
<label {...getLabelProps()}>Books:</label>
<div {...getToggleButtonProps()}></div>
<ul {...getMenuProps()}>
{isOpen ? items.forEach(/* render books if open */) : null}
</ul>
</div>
)

This will build a markup similar to:

<div>
<label id="label-id-1" for="toggle-id-1">Books:</label>
<div
role="combobox"
aria-expanded="false"
tabindex="0"
aria-controls="menu-id-1"
aria-haspopup="listbox"
aria-activedescendant
aria-labelledby="label-id-1"
></div>
<ul role="listbox" id="menu-id-1" aria-labelledby="label-id-1">
</div>

The Requirements

  1. Clicking the <label> should place the focus on the combobox element.

This would have been done automatically if the labelled element would have been an <input> or a <button>, given how the label's for attribute works by default. However, if the labelled element, here the combobox, is a <div> (ARIA 1.2 spec), the focus must be added manually via a click event. We would have the same issue if we replace the <label> with something else, so another reason to add this behaviour.

  1. Passing an onClick to getLabelProps should not interfere with the default focus behaviour we will be adding.

For instance, calling the code below should focus the toggle button and display the log.

<label
{...getLabelProps({
onClick() {
console.log('Clicked!')
},
})}
/>
  1. Passing an onClick to getLabelProps adds the preventDownshiftDefault attribute on the click event object should not focus the toggle anymore.

It's a practice similar to the event's preventDefault, where we can control the default behaviour of the event. In Downshift's case, we want to be able to stop the Downshift default behaviour.

<label
{...getLabelProps({
onClick(e) {
e.preventDownshiftDefault = true

document.querySelector('#better-element').focus()
},
})}
/>

The Tests

That's the beauty of drafting a list of requirements, you could use it to write the tests. Downshift is a React library, so we are going to use React Testing Library and Jest in order to check our changes will be correct.

Our first test is going to be easier, since we only want to render the Select, click on the label, and check if the toggle has focus. To render the component, we will use RTL render method with a JSX similar to the example when describing the problem.

function renderSelect() {
const utils = render(<Select />)

return utils
}

function Select() {
const {getToggleButtonProps, getLabelProps, getMenuProps, isOpen, ...rest} =
useSelect({
items,
})

return (
<div>
<label {...getLabelProps()}>Books:</label>
<div {...getToggleButtonProps()}></div>
<ul {...getMenuProps()}>
{isOpen ? items.forEach(/* render books if open */) : null}
</ul>
</div>
)
}

With these abstractions in place, our test becomes:

test('clicking the label moves focus on the toggle element', async () => {
renderSelect()

// RTL's user-event tool
await user.click(screen.getByText('Books:', {selector: 'label'}))

// RTL jest-dom extends Jest's expectations to include "toHaveFocus".
expect(screen.getByRole('combobox', {name: 'Books:'})).toHaveFocus()
})

And we're done. We render, we click the label, we check if the focus is where we need it to be. We run the test, it will fail, it's a success, we go grab lunch.

Now, something about the other 2 requirements. These are part of Downshift's API for some time now, and they already have a utility function that chains callbacks together and calls all of them on a event trigger. This utility also knows not to call Downshift's default event handler if "preventDownshiftDefault" has been passed. We are not going to test this utility now, we just want to make sure it works for our callback. Sure, we can just mock that utility and check the call, since we also test the utility in isolation to make sure that it works. But it's better to test for the actual user consequences rather than implementation details, so let's write the tests for these last requirements.

For the second requirement, if we pass our own onClick to getLabelProps, it should get called along with the default handler that sets focus. For this particular test, we don't need to render the whole component, we can just render the hook, like this.

export function renderUseSelect(props) {
// renderHook from RTL
return renderHook(() => useSelect({items, ...props}))
}

Now, we need to find a way to check that our custom onClick is called when the label gets clicked. Which, in turn, means that we need to actually trigger that click, since we are not going to render actual markup, we are just testing the hook. We know that getLabelProps will return an onClick that should end on a label element, so all we have to do is to pass a Jest spy as an onClick argument to getLabelProps, then call the onClick returned, and check the spy.

test('event handler onClick is called along with downshift handler', () => {
const userOnClick = jest.fn()
const {result} = renderUseSelect()

const {onClick} = result.current.getLabelProps({
onClick: userOnClick,
})

onClick()

expect(userOnClick).toHaveBeenCalledTimes(1)
})

Simple! Now we test that whatever we pass in the onClick, it will be called with Downshift's handler. Which is something we don't test at the moment, and that's a huge potential problem over there. We don't want a regression to slip in one day and ruin everyone's Select elements if they also wanted to pass their own event handlers to our prop getters, which is a very common use case.

This is the part of the TDD where we need to think about the implementation. To manually focus the toggle element, we need to use a React ref on the combobox toggle JSX element, and use that ref object to perform a manual .focus(). Consequently, there's going to be a ref object returned by getToggleButtonProps, as it needs to be passed to the toggle element. We will use the ref in the useSelect code in order to perform the focus action. In our test, we just need to grab this returned ref and call it with a mocked object that has a focus function attached to it, which will be a jest spy. If our implementation works, the object we pass as argument to the ref function call is going to be used by the internal code to perform the focus. The test becomes:

test('event handler onClick is called along with downshift handler', () => {
const userOnClick = jest.fn()
const mockToggleElement = {focus: jest.fn()}
const {result} = renderUseSelect()

const {onClick} = result.current.getLabelProps({
onClick: userOnClick,
})
const {ref} = result.current.getToggleButtonProps()

ref(mockToggleElement) // simulate that React will add the toggle element in the ref.
onClick() // now we should have the ref containing the toggle element, we can click the label.

expect(userOnClick).toHaveBeenCalledTimes(1)
expect(mockToggleElement.focus).toHaveBeenCalledTimes(1)
})

We also need to check that setting "preventDownshiftDefault" will not focus the toggle element. The test is going to be similar to the previous one, only that the userOnClick implementation will set this attribute on the event object.

test('event handler onClick is called along with downshift handler', () => {
const userOnClick = jest.fn(event => {
event.preventDownshiftDefault = true
})
const mockToggleElement = {focus: jest.fn()}
const {result} = renderUseSelect()

const {onClick} = result.current.getLabelProps({
onClick: userOnClick,
})
const {ref} = result.current.getToggleButtonProps()

ref(mockToggleElement) // simulate that React will add the toggle element in the ref.
onClick() // now we should have the ref containing the toggle element, we can click the label.

expect(userOnClick).toHaveBeenCalledTimes(1)
expect(mockToggleElement.focus).not.toHaveBeenCalled()
})

We are in a very good spot right now. We have written great tests that communicate exactly what our goal is, and that will only smoothen the implementation process. While deciding on the requirements and writing the tests we can also get better feedback from all stakeholders, since we already have a list of objectives, and it's easier to collaborate on that. We could get extra requirements (more tests) or, even better, some may decide that some requirements are superfluous. So what we may need to remove some already written tests? Less code is good.

Implementation

The implementation is rather easy. Given the current code, we will add another onClick function to be returned from getLabelProps, and we will use the toggleButtonRef we already have in order to focus the element.

Before:

const getLabelProps = useCallback(
labelProps => ({
id: elementIds.labelId,
htmlFor: elementIds.toggleButtonId,
...labelProps,
}),
, [elementIds]
)

After:

const getLabelProps = useCallback(
({onClick, ...labelProps} = {}) => {
const labelHandleClick = () => {
toggleButtonRef.current?.focus()
}

return {
id: elementIds.labelId,
htmlFor: elementIds.toggleButtonId,
onClick,
...labelProps,
}
},
[elementIds],
)

Success! One test is passing already, with 2 more to go. Once these are green, we can agree that our implementation is a success. And that's the easiest part. We will also use our callAllEventHandlers utility in order to chain the callbacks and also check for the "preventDownshiftDefault" property.

const getLabelProps = useCallback(
({onClick, ...labelProps} = {}) => {
const labelHandleClick = () => {
toggleButtonRef.current?.focus()
}

return {
id: elementIds.labelId,
htmlFor: elementIds.toggleButtonId,
onClick: callAllEventHandlers(onClick, labelHandleClick),
...labelProps,
}
},
[elementIds],
)

Utility Function for Reference

The callAllEventHandlers utility will call the functions passed to it, in order, until one of them passes the "preventDownshiftDefault". It's important to pass the focus setting handler at the end. If you need such a function in your life, here it is:

/**
* This is intended to be used to compose event handlers.
* They are executed in order until one of them sets
* `event.preventDownshiftDefault = true`.
* @param {...Function} fns the event handler functions
* @return {Function} the event handler to add to an element
*/
function callAllEventHandlers(...fns) {
return (event, ...args) =>
fns.some(fn => {
if (fn) {
fn(event, ...args)
}
return (
event.preventDownshiftDefault ||
(event.hasOwnProperty('nativeEvent') &&
event.nativeEvent.preventDownshiftDefault)
)
})
}

You also need to properly compose the ref objects. In our case, the toggle element is going to be used by useSelect, and it may also be used in the component using useSelect. Also for reference, here's the ref composing function and its usage.

const toggleProps = {
// refKey could be ref, could be innerRef, up to the user
[refKey]: handleRefs(ref, toggleButtonNode => {
toggleButtonRef.current = toggleButtonNode
}),
// ... the rest of the props returned by getToggleButtonProps
}

function handleRefs(...refs) {
return node => {
refs.forEach(ref => {
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
})
}
}

Last Changes and Merging

We can create a new branch and commit our new changes. Given the nature of Downshift, we have TS files separately, and sometimes they also need to be updated. Now it's such a case, given that we changed the type of a return value:

export interface UseSelectGetLabelPropsReturnValue
extends GetLabelPropsReturnValue {
onClick: React.MouseEventHandler
}

We also need to update the documentation, to make it clear to our users that we added an event handler to the label props. We also want to mention what the event does.

#### Label

- `Click`: It will move focus to the toggle element.

Finally, we run the validation script, which in Downshift's case, it runs all the unit tests, the coverage check, the e2e tests, linting and all that stuff. Afterwards, we push the changes, create a PR, let the same validation script run via GitHub actions, and if the changes look good, we will merge the PR, close the issue, close the laptop and run. We should get an email from npm that the library has been updated successfully. In our case, it should be a minor version bump, since it's a feature.

Conclusion

I don't always do TDD, but when I do, I write about it. It's a very neat process, and I am super thankful to Kent and his Testing Javascript teachings. I was always passionate about automated testing, but his course just completely changed everything for me. If you haven't done it yet, I strongly recommend it, it's so worth it. Tip: try to get purchase parity for it if you aim to use it in your own home country only. You will get a discount.

I hope my Downshift TDD experience helps you write better and test better in the feature. If it sparked your interest in using TDD more or just to learn to test better, I declare myself happy for it. Good luck in writing great code and improving the world through your work!

Overwriting Property Types in TypeScript

· 14 min read

the library in the morgan museum, new york The Morgan Library Museum in New York. Photo by Silviu Alexandru Avram

TypeScript is a great alternative to JavaScript if you feel the need for that extra safety net of strong typing. Most of the tyme, TS is pretty straightforward, as we are familiar with strong type languages like Java. However, when we do require a tiny bit of dynamic type magic, things can stop being straightforward, and we can end up being very creative. But when we actually get the job done and even improve on the result, we realise how strong TS can be, and we are super thankful for its existence.

In this article, we are going to touch concepts like TypeScript as a language in general, but also Generics and some TS utilities that are available globally. Solving the problem below helped me understand TS better, and I hope it helps you as well. Let's begin.

The Use Case

The JS code below is the one that required some creativity from my part, when improving Typescript types for downshift. For simplicity, I removed some React specific details, since they are not relevant to the topic.

function getToggleButtonProps({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
} = {}) {
function toggleButtonHandleClick() {
dispatch({
type: stateChangeTypes.ToggleButtonClick,
})
}
const toggleButtonProps = {
[refKey]: handleRefs(ref, toggleButtonNode => {
toggleButtonRef.current = toggleButtonNode
}),
'aria-controls': elementIds.menuId,
'aria-expanded': latestState.isOpen,
id: elementIds.toggleButtonId,
tabIndex: -1,
}

if (!rest.disabled) {
if (isReactNative || isReactNativeWeb) {
toggleButtonProps.onPress = callAllEventHandlers(
onPress,
toggleButtonHandleClick,
)
} else {
toggleButtonProps.onClick = callAllEventHandlers(
onPress,
toggleButtonHandleClick,
)
}
}

return {
...toggleButtonProps,
...rest,
}
}

Our function, getToggleProps, receives some props as an Object, which is destructured directly.

  • we are using an empty object as default parameter (= {}), otherwise our destructuring will fail for undefined. Extra safety.
  • we are grabbing a few properties from the object, such as onPress, onClick, ref and refKey, as we are using them in our function.
  • we are using a prop without grabbing it from the object, rest.disabled.
  • we are returning our computed props as toggleButtonProps, along with rest, which are the rest of the props we did not change. We bundled them together by object spreading and returned them.

The Goal

Since this function is written in JS, in order to convert it to TS, or at least provide TS types for it, we need to define the types for both the function parameter and the function return value. The function's purpose is to return attributes that are going to be applied to a HTML button element, but not always. It could be a React Native button as well, or a custom React button component from any UI Library. Consequently, our types requirements are the following:

  1. The parameter should accept HTML button props, in the case of an HTML button, or any custom UI component that also accepts HTMP button props.
  2. The parameter should accept refKey, a string prop used for those rare cases where the component we are passing toggleButtonProps to has a different name for the ref prop, for instance innerRef.
  3. The parameter should also accept an optional onPress, in case we are in the context of React Native.
  4. The parameter should also accept any prop that needs to end up on the element, by being passed through with the rest object.
  5. The return value needs to return the props that are being computed and guaranteed to be returned in toggleButtonProps: aria-controls, aria-expanded, id and tabIndex.
  6. The return value needs to optionally return either onClick or onPress, depending on whether we are on React Native or not.
  7. The return value needs to return those props from #4 that could be anything, but we should also include their type in the return type.
  8. In case there is a type overlap between a prop from the parameter and the one from the return value, the parameter prop type wins.
  9. We cannot do anything for refKey, since that can be any string, with a 'ref' default, so the most we could do here is an optional ref prop.

Implementation

Let's nail the easy static types first, then we will work towards the dynamic override aprt.

HTML Button Props

The parameter should accept HTML button props, in the case of an HTML button, or any custom UI component that also accepts HTMP button props.

For this first part, the implementation is really simple, we just need to make the parameter accept HTMLButtonProps.

function getToggleButtonProps({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: React.HTMLProps<HTMLButtonElement> = {}) {
// implementation
}

The generic-based React.HTMLProps<HTMLButtonElement> is a great way to achieve our goal, since it will return all the props that could end up on an HTML button. And all of them are marked as optional.

Very easy so far, but there's one problem. TypeScript will complain about refKey, since the prop is not part of React.HTMLProps<HTMLButtonElement>. And there's also onPress. That's not part of the HTML Button either, since it's a prop specific to React Native. Let's fix that.

The refKey Prop

The parameter should accept refKey, a string prop used for those rare cases where the component we are passing toggleButtonProps to has a different name for the ref prop, for instance innerRef.

In order to fix our code, we can just add the two props with a type intersection, like so:

function getToggleButtonProps({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: React.HTMLProps<HTMLButtonElement> & {
refKey?: string
} = {}) {
// implementation
}

Done! Now the props are the intersection between the button props and the ref type we just created, the only thing we need to accept is onPress.

The onPress Prop

The parameter should also accept an optional onPress, in case we are in the context of React Native.

The most obvious fix would be to add onPress to the type we just created to support the refs.

function getToggleButtonProps({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: React.HTMLProps<HTMLButtonElement> & {
refKey?: string
onPress?: (event: React.BaseSyntheticEvent) => void
} = {}) {
// implementation
}

For simplicity, we are using React.BaseSyntheticEvent, which is goint to be a common interface for both web and native event handler props in React, since we don't plan to also import React Native types.

The errors are gone and we handled the first 3 use cases, which is great. Since we may want to support other functions as well, not just for toggle buttons, we may want to split the above type into several, which could be used independently. Also, we may want to extend these types, so we should consider using interfaces instead of types. Consequently, our code will become:

interface GetPropsWithRefKey {
refKey?: string
}

interface GetToggleButtonProps
extends React.HTMLProps<HTMLButtonElement>,
GetPropsWithRefKey {
onPress?: (event: React.BaseSyntheticEvent) => void
}

function getToggleButtonProps({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: GetToggleButtonProps = {}) {
// implementation
}

Now the refKey part is abstracted away in GetPropsWithRefKey, and can be used by other functions, while the button specific types are bundled together in GetToggleButtonProps, along with the refKey part, through extension.

Pass-through Props

The parameter should also accept any prop that needs to end up on the element, by being passed through with the rest object.

Now comes the hard part. We want to accept anything else the user may need to pass through our function, but still keep our previous GetToggleButtonProps definition for the props. Since we have no idea what the user is going to pass in addition to the types we already defined, we will use a generic type, which we will intersect with GetToggleButtonProps, such that it will include stuff from both. Let's actually write it down:

function getToggleButtonProps<Rest>({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: GetToggleButtonProps & Rest = {}) {
// implementation
}

Rest can be anything, and it does not have to be specified directly when calling the function. For example, this call will be considered correct:

function onClick() {
console.log('clicked')
}

const toggleProps = getToggleButtonProps({onClick, foo: 'bar'})

onClick is part of the GetToggleButtonProps interface, while the foo string property is part of the Rest, and, according to the function implementation, it will be passed directly to the return value. The Rest generic will be super valuable when defining the return value type, as we shall see in a moment.

Guaranteed Returned Props

The return value needs to return the props that are being computed and guaranteed to be returned in toggleButtonProps: aria-controls, aria-expanded, id and tabIndex.

Of course, this part is the easy one, and we will create an interface that defines the props above and their types, according to the implementation.

function getToggleButtonProps<Rest>({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: GetToggleButtonProps & Rest = {}): {
'aria-controls': string
'aria-expanded': boolean
id: string
tabIndex: -1
} {
// implementation
}

Each property is defined as non-optional, since it is guaranteed that we are returning it in the returned object. We can also be super specific with our types, for instance tabIndex is -1 instead of number, as we are going to always return -1, while the other properties are dynamic.

Optional Event Handler Props

The return value needs to optionally return either onClick or onPress, depending on whether we are on React Native or not.

Let's also add the couple of optional handler props and also abstract the whole return value prop into an interface.

interface GetPropsWithRefKey {
refKey?: string
}

interface GetToggleButtonProps
extends React.HTMLProps<HTMLButtonElement>,
GetPropsWithRefKey {
onPress?: (event: React.BaseSyntheticEvent) => void
}

interface GetToggleButtonReturnValue {
'aria-controls': string
'aria-expanded': boolean
id: string
onPress?: (event: ReactNative.GestureResponderEvent) => void
onClick?: React.MouseEventHandler
tabIndex: -1
}

function getToggleButtonProps<Rest>({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: GetToggleButtonProps & Rest = {}): GetToggleButtonReturnValue {
// implementation
}

Looks great! We now have interfaces for both the function parameter and the return value. We are almost at the finish line.

Rest in the Return Value

The return value needs to return those props from #4 that could be anything, but we should also include their type in the return type.

Now it's time to use again the Rest generic we defined previously, and include it in the return value, since it contains the pass-through props. We will use the intersection here, again.

function getToggleButtonProps<Rest>({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: GetToggleButtonProps & Rest = {}): GetToggleButtonReturnValue & Rest {
// implementation
}

Now, everything extra that is passed as rest will be reflected in the type for the return value.

TypeScript VSCode snippet reflecting the rest prop being present in the return value

We just crossed the finish line with half a leg.

Prop Overrides

In case there is a type overlap between a prop from the parameter and the one from the return value, the parameter prop type wins.

Suppose that we want to make our toggle button focusable and add it to the tab order of the page. We will need to pass tabIndex as 0, or any other positive value (big anti pattern, but you can). With our current type definitions, if we pass tabIndex: 0 as a prop to the function, the return value type of tabIndex will stay as -1, since that's what we defined in the GetToggleButtonReturnValue interface.

TypeScript VSCode snippet reflecting the tabIndex prop type being -1 though it has the value 0

Sure, we can just make the type to be number and move on, but that's not what we are actually returning by default. And it's not just about the tabIndex. We can have, for instance, another prop returned by default, such as aria-haspopup, with a listbox default value. But what if the user needs to display the popped out menu as a modal? They would need to pass aria-haspopup: 'dialog' to the function, and the return type for aria-haspopup should reflect the parameter type.

For this reason, we need to create a type that overrides the type we define by default for the props that are passed as parameter and have a different type. Of course, this is not required if those props passed as parameter are destructured away from rest, and in this case we can keep our own type, as the value won't be overriden by pass-through. It's not the case for tabIndex, so we need to override the type here. And the type definition I found to solve the issue is the following, from this thread:

type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U

Whoa, whoa, what? Ok, let's try to remember what we want to achieve. If we pass a prop with the same name as the function parameter, then it should override the type from GetToggleButtonReturnValue.

Let's consider GetToggleButtonReturnValue to be T and Rest to be U. Overwriting T with U means picking from T only the prop types that are not common between T and U. We exclude from T the props from U, which means there will be no common props as a result of this exclusion, and then we pick these exact props from T. Finally, we intersect these non-common props with U, giving us back the common props (if any), but this time with the types from U. Remember, U is Rest, our pass-through props type.

Consequently, our return type will become:

type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U

function getToggleButtonProps<Rest>({
onClick,
onPress,
refKey = 'ref',
ref,
...rest
}: GetToggleButtonProps & Rest = {}): Overwrite<
GetToggleButtonReturnValue,
Rest
> {
// implementation
}

And voilà, the result:

TypeScript VSCode snippet reflecting the tabIndex prop type being number in the returned value due to type override

Also important to point out, due to the current type of the parameter GetToggleButtonProps & Rest, you cannot pass just any type to the properties that are statically defined, such as tabIndex, onClick, refKey and the others. Passing a number for the onClick prop will fail since it's clearly defined in the React.HTMLProps<HTMLButtonElement> as a function with certain parameter types. You could pass tabIndex as 0 due tot the fact that it's a number type in the React.HTMLProps<HTMLButtonElement>, even though we made it -1 in our return type, as a result of our function implementation.

The Ref Prop

We cannot do anything for refKey, since that can be any string, with a 'ref' default, so the most we could do here is an optional ref prop.

This is going to be our only limitation. Passing a custom string as a refKey will not reflect a property with the key as that string value in the return type. There is no way (that I know) to get the string value from an object key and use that as a key in a type. Consequently:

TypeScript VSCode snippet reflecting the customInnerRef string value for refKey not ending in the return type

There's no customInnerRef property in the return value, just the ref optional property that is going to be passed by default, in case we don't pass any value for refKey (refKey = 'ref',).

Conclusion

In this post, we went through defining static types for a function's paramater and return value, as well as allowing dynamic types to override previously defined static types, where such a case was needed. We also defined an initial set of expectations, starting from a working example, then we went through the use cases one by one and altered the solution accordingly. We also saw a few usages of TS helper types, such as Pick, Exclude and others.

Improving the Types in Downshift was a great experience, as there were quite a few use cases to consider for the getter prop functions. I found the experience so useful that I considered to write in detail about it, and I hope you will find it useful in some way as well.