Overwriting Property Types in TypeScript
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 forundefined
. Extra safety. - we are grabbing a few properties from the object, such as
onPress
,onClick
,ref
andrefKey
, 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 withrest
, 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:
- 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.
- 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.
- The parameter should also accept an optional onPress, in case we are in the context of React Native.
- The parameter should also accept any prop that needs to end up on the
element, by being passed through with the
rest
object. - 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.
- The return value needs to optionally return either onClick or onPress, depending on whether we are on React Native or not.
- 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.
- 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.
- 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.
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.
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:
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:
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.