Typescript: how to declare a type that includes all types extending a common type? - reactjs

TLDR: Is there a way in Typescript to declare a type that encompasses all types that extend a given interface?
My specific problem
I am writing a custom React hook that encapsulates logic for deciding whether or not an element is moused over. It is modelled roughly after this hook. It exposes a ref that should be able to take any HTMLElement:
const ref = useRef<HTMLElement>(null);
The problem is, if I try to use this ref on any specific React element, I get an error telling me that this specific element is not quite HTMLElement. For example, if I use it with HTMLDivElement, I get this error: argument of type HTMLElement is not assignable to parameter of type HTMLDivElement.
Here's a simple repro case of the problem above in Typescript playground
Obviously, I wouldn't want to list types of all html elements in my hook. Given that HTMLDivElement extends the HTMLElement type, is there a way of declaring that the type that I am actually after is not strictly HTMLElement, but whatever extends HTMLElement?
React code example
source code of the hook
import { useRef, useState, useEffect } from 'react';
type UseHoverType = [React.RefObject<HTMLElement>, boolean];
export default function useHover(): UseHoverType {
const [isHovering, setIsHovering] = useState(false);
let isTouched = false;
const ref = useRef<HTMLElement>(null); // <-- What should the type be here?
const handleMouseEnter = () => {
if (!isTouched) {
setIsHovering(true);
}
isTouched = false;
};
const handleMouseLeave = () => {
setIsHovering(false);
};
const handleTouch = () => {
isTouched = true;
};
useEffect(() => {
const element = ref.current;
if (element) {
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
element.addEventListener('touchstart', handleTouch);
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
element.removeEventListener('touchend', handleTouch);
};
}
}, [ref.current]);
return [ref, isHovering];
}
which produces type error if used like this:
import useHover from 'path-to-useHover';
const testFunction = () => {
const [hoverRef, isHovered] = useHover();
return (
<div
ref={hoverRef}
>
Stuff
</div>
);
}
Type error in example above will be:
Type 'RefObject<HTMLElement>' is not assignable to type 'string | RefObject<HTMLDivElement> | ((instance: HTMLDivElement | null) => void) | null | undefined'.
Type 'RefObject<HTMLElement>' is not assignable to type 'RefObject<HTMLDivElement>'.
Property 'align' is missing in type 'HTMLElement' but required in type 'HTMLDivElement'.

I think you are mistaken about the direction of the assignment that fails. If you have an interface A, then the type that matches all subclasses of A is just called A. This way, HTMLElement (i.e. is assignable from) any HTML element, e.g. HTMLDivElement.
This means that if you have a bunch of functions, one of them accepts HTMLDivElement, another accepts HTMLLinkElement etc, then there is no real type that you can pass to all of them. It would mean you expect to have an element that is both a div and a link and more.
Edited based on your edits of the question:
If the code you have works fine, and your only problem is that it doesn't compile, then just make your useHover generic, like this:
type UseHoverType<T extends HTMLElement> = [React.RefObject<T>, boolean];
function useHover<T extends HTMLElement>(): UseHoverType<T> {
const ref = useRef<T>(null); // <-- What should the type be here?
...
And then:
const testFunction = () => {
const [hoverRef, isHovered] = useHover<HTMLDivElement>();
Something like this will make your code compile fine, without changing its runtime behaviour. I'm unable to tell if the runtime behaviour right now is as desired.

It works as expected, since HTMLDivElement extends HTMLElement. In your typescirpt playground you mixed it up. I updated it by switching x and y in this playground. You want the function to extend HTMLElement and pass y, which is and HTMLDivElement into it. And that works.

Related

TypeScript React createContext() default value

I been kinda stumped for a couple hours trying to figure out what to set as my default value on my createContext function. This is my code.
// PetTypeProvider.tsx
import { useState, createContext, useContext } from 'react';
const PetTypeContext = createContext('');
const UpdatePetTypeContext = createContext((event:React.MouseEvent<HTMLElement>) => event);
export function usePetType() {
return useContext(PetTypeContext)
}
export function useUpdatePetType() {
return useContext(UpdatePetTypeContext)
}
interface PetTypeProviderProps {
children: JSX.Element|JSX.Element[];
}
export const PetTypeProvider: React.FC<PetTypeProviderProps> = ({children}) => {
const [petType, setPetType] = useState('Dogs');
const togglePet = (event:React.MouseEvent<HTMLElement>) => setPetType(event.currentTarget.innerText);
return (
<PetTypeContext.Provider value={petType}>
<UpdatePetTypeContext.Provider value={togglePet}>
{children}
</UpdatePetTypeContext.Provider>
</PetTypeContext.Provider>
);
};
On my <UpdatePetTypeContext.Provider> I set the value to my toggle function, which switches the pet type to which ever is selected.
const togglePet = (event:React.MouseEvent<HTMLElement>) => setPetType(event.currentTarget.innerText);
TS compiler is yelling at me with this
Type '(event: React.MouseEvent<HTMLElement>) => void' is not assignable to type '(event: React.MouseEvent<HTMLElement>) => React.MouseEvent<HTMLElement, MouseEvent>'.
Type 'void' is not assignable to type 'MouseEvent<HTMLElement, MouseEvent>'.ts(2322)
index.d.ts(329, 9): The expected type comes from property 'value' which is declared here on type 'IntrinsicAttributes & ProviderProps<(event: MouseEvent<HTMLElement, MouseEvent>) => MouseEvent<HTMLElement, MouseEvent>>'
Thanks for taking the time to read my issue
I have tried setting the to const PetTypeContext = createContext(MouseEvent); which didn't work. I honestly just need the correct default value and data type for TS and I am just lost. The code works, but TS compiler doesn't like it since no default value is given.
Try the following
const UpdatePetTypeContext = createContext((event:React.MouseEvent<HTMLElement>) => {});
You want to have a function that returns nothing, not a function that returns the event itself.
I'm pretty sure you don't want to return event from the handler
change:
(event:React.MouseEvent<HTMLElement>) => event
to:
(event:React.MouseEvent<HTMLElement>) => {}

Typescript React: generic-based callback event handler function as prop to component

I'm having trouble creating a generic-based callback event handler function that I want to pass as prop down to my component.
My goal: Allow user to pass a custom callback function that:
always takes in the same argument
an event (not a react/dom event handler event, its coming from a library)
can return different return type
for my use cases, this component is used in different context's so in one place a user will return a certain value over another
What I have Attempted
I have my onNodeClick function defined using generics & when i use it in isolation, it works.
// ✅ Simple example of calling a generic function
const onNodeClick = <T,> (event:any) => {
return null as unknown as T;
}
const string_result = onNodeClick<string>(event_from_somewhere)
However, when I try to pass this method as a prop to my component, I am getting errors. I'm unsure of how to resolve it
Live Typescript Code Playground
import React from 'react';
type NodeComponentProps = {
onNodeClick: <T>(event: any) => T;
};
export const NodeComponent = ({onNodeClick}: NodeComponentProps) => {
return null;
}
const Homepage = () => {
const handleNodeClick = <T,>(event: any): T => {
return null as unknown as T;
};
return (
<NodeComponent
onNodeClick={(event): string => {
const string_result = handleNodeClick<string>(event); // ✅ correct type
return string_result; // ❌ onNodeClick is throwing type error; see error message a few lines below
}}
/>
)
}
/*
Type '<T>(event: any) => string' is not assignable to type '<T>(event: any) => T'.
Type 'string' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type which could be unrelated to 'string
*/
I think you want the type to be generic, not the function. If the function is generic, then it should be callable with angle brackets and work correctly. For example, the identity function works with any type, so it should pass your generic function definition:
const Homepage = () => {
const onNodeClick = <T>(x: T): T => {
return x;
};
return (
<NodeComponent
onNodeClick={onNodeClick}
/>
)
}
However, the function you used will always return a string. If you tried to call it with <number>, it would fail. Therefore, not a generic function.
By contrast, if you make the type generic, you should be able to specify you are using it with a string:
Here is a Playground link
type NodeComponentProps<T> = {
onNodeClick: (event: any) => T;
};
export const NodeComponent = <T,>({onNodeClick}: NodeComponentProps<T>) => {
return null;
}

Annotating Custom React Hook With TypeScript

I have a custom React Hook that watches for a click outside of a specific element. It works just fine, but I am having trouble making TypeScript happy in a few places.
App.js
import { useRef, useCallback } from "react";
import useClick from "./useClick";
export default function App() {
const asideRef = useRef(null);
const handleStuff = useCallback(() => {
console.log("a click outside of the sidebar occurred.");
}, []);
useClick(asideRef, handleStuff);
return (
<div className="App">
<aside ref={asideRef}>
<nav>
<ul></ul>
</nav>
</aside>
</div>
);
}
useClick.js
import React, { useEffect } from "react";
const useClick = (ref: React.MutableRefObject<HTMLElement>, cb: () => void) => {
useEffect(() => {
const checkClick = (e: React.MouseEvent): void => {
if (ref.current && !ref.current.contains(e.target as Node)) {
cb();
}
};
document.addEventListener("click", checkClick);
return () => {
document.removeEventListener("click", checkClick);
};
}, [ref, cb]);
};
export default useClick;
The first problem area is in App.js, where the useClick hook is called. TypeScript complains about the first parameter passed to useClick and gives a message of:
Argument of type 'MutableRefObject<null>' is not assignable to parameter of type 'MutableRefObject<HTMLElement>'.
Type 'null' is not assignable to type 'HTMLElement'.ts(2345)
I know this has something to do with me setting the initial value of the ref to null, and the argument for the ref in useClick being annotated as React.MutableRefObject<HTMLElement>. I just don't know how to fix it.
The second problem area is in useClick.js, where the event listeners are added and removed. TypeScript seems to have a problem with my checkClick function. The error is so long that I have no choice but to show a photo of it below.
If anyone has any idea's on how to fix these two issues, so TypeScript will be happy, the help would be greatly appreciated.
Hi Dan: I'll start with what needs to change, then explain why below. (I also renamed your TypeScript file extensions to reflect their content.)
useClick.ts
Before:
// ...
const useClick = (ref: React.MutableRefObject<HTMLElement>, cb: () => void) => {
useEffect(() => {
const checkClick = (e: React.MouseEvent): void => {
// ...
After:
// ...
const useClick = (ref: React.RefObject<HTMLElement>, cb: () => void) => {
useEffect(() => {
const checkClick = (e: MouseEvent): void => {
// ...
App.tsx
Before:
export default function App() {
const asideRef = useRef(null);
After:
export default function App() {
const asideRef = useRef<HTMLElement>(null);
First, let's address the checkClick function: This function signature should be assignable to the EventListener type in lib.dom.d.ts:
interface EventListener {
(evt: Event): void;
}
Because your parameter is React.MouseEvent, which does not extend the native Event, type, it is incompatible. Just use the native MouseEvent type instead, since you're adding the listener to the document (which has nothing to do with React anyway.)
Next, let's look at the function signature for useClick:
You might want to reference the type definitions for useRef while reading this part:
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/5149827c1f97541dd69d950039b83ace68e119e6/types/react/index.d.ts#L1025-L1063
The first parameter is the only one that needs to change, and it needs to change from a MutableRefObject to a RefObject. The difference between these can be subtle, but there are some great resources you can read to learn more about them. Here's one, for example:
https://dev.to/wojciechmatuszewski/mutable-and-immutable-useref-semantics-with-react-typescript-30c9
When creating a ref that you pass to React to use for an element reference, you should provide null as the initial argument to useRef (which you did) and also provide a generic type annotation for the type of value it should hold (in this case it's HTMLElement). When using a ref for a DOM element, React expects that you will never mutate this ref since it's being controlled by React, so the type that is returned by useRef in this case is simply a RefObject. Therefore, that's what you should use for the first parameter of useClick.

How to properly type define event.target.value?

I'm authoring a node package but I'm having bit of an issue with my typescript definitions. To be more specific I find the definition of event.target.value super confusing
Issue description:
I have the following event handler:
import { ChangeEvent, useState } from 'react'
type FieldEvent = ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
export const useField = <T>(input: T) => {
const [value, setValue] = useState<T>(input)
const handleChange = (event: FieldEvent) => {
const { name, value: eventValue } = event.target
// #ts-expect-error
setValue(eventValue)
}
return [input, handleChange]
}
The expression setValue(eventValue) results in the following error:
Argument of type 'string' is not assignable to parameter of type 'SetStateAction<T>'.
I was a bit surprised by this, given a lot of exported components use different event.target.value. Eg date-picker return Date type, select Object, etc.
Issue investigation
Naturally I went to check the imported ChangeEvent react exports to see if it has correct definitions, but this appears to be correct
interface ChangeEvent<T = Element> extends SyntheticEvent<T> {
target: EventTarget & T;
}
so according to this definition it should inherit the type of the Element that was passed to the SyntheticEvent
so I followed the chain to the HTMLInputElement declaration located in node_modules/typescript/lib/lib.dom.d.ts which is where the crux of the issue lies
interface HTMLInputElement extends HTMLElement {
value: string
//... rest
}
I checked back and it appears all the native <input> elements default to string as their value type, which I guess make sense.
Solving the issue
Obviously this is not ideal, given this does not represent the event.target.value behavior in a lot of the reactjs projects that use third-party-packages (which my package is supposed to support). Consider the following codesandbox
The returned event.target.value is as you'd expect of typeof number
that leads me to the question, should I simply override the ChangeEvent with the following definition?
ChangeEvent<{ value: T, name: string } & HTMLInputElement>
or would this be considered a bad practice? Or is there some better way to go about doing this altogether?
handleChange is not match to required params.
I've tried and it worked:
export default function App() {
const [selected, setSelected] = useState(1);
const handleChange = (e: ChangeEvent<{
name?: string | undefined,
value: unknown | number
}>, child: React.ReactNode) => {
setSelected(e.target.value as number);
};
return (
<Select value={selected} onChange={handleChange}>
<MenuItem value={1}>One</MenuItem>
<MenuItem value={2}>Two</MenuItem>
<MenuItem value={3}>Three</MenuItem>
</Select>
);
}
Alright, I'm not 100% sure if this is the correct approach but it seems to work fine for my use-case, albeit the typing seems a tiny bit odd, but basically I'm overwriting the passed type argument to ChangeEvent and extending it by a union of one the HTML elements.
export type FieldEvent<T> = ChangeEvent<
{ value: T, name?: string } &
(HTMLInputElement | HtmlTextAreaElement | HTMLSelectElement)
>
This overwrites the type definition of the ChangeEvent, then you just need to create a handler function that extends the type argument
export type FieldHanderFunction<T> = (event: FieldEvent<T>) => void
so then inside my hook, it basically comes down to:
const useField<T> = (input: T) => {
const handleChange = (event: FieldEvent<T>) => {
// ...
}
}

What typescript type do I use with useRef() hook when setting current manually?

How can I use a React ref as a mutable instance, with Typescript? The current property appears to be typed as read-only.
I am using React + Typescript to develop a library that interacts with input fields that are NOT rendered by React. I want to capture a reference to the HTML element and then bind React events to it.
const inputRef = useRef<HTMLInputElement>();
const { elementId, handler } = props;
// Bind change handler on mount/ unmount
useEffect(() => {
inputRef.current = document.getElementById(elementId);
if (inputRef.current === null) {
throw new Exception(`Input with ID attribute ${elementId} not found`);
}
handler(inputRef.current.value);
const callback = debounce((e) => {
eventHandler(e, handler);
}, 200);
inputRef.current.addEventListener('keypress', callback, true);
return () => {
inputRef.current.removeEventListener('keypress', callback, true);
};
});
It generates compiler errors: semantic error TS2540: Cannot assign to 'current' because it is a read-only property.
I also tried const inputRef = useRef<{ current: HTMLInputElement }>(); This lead to this compiler error:
Type 'HTMLElement | null' is not assignable to type '{ current: HTMLInputElement; } | undefined'.
Type 'null' is not assignable to type '{ current: HTMLInputElement; } | undefined'.
Yeah, this is a quirk of how the typings are written:
function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T|null): RefObject<T>;
If the initial value includes null, but the specified type param doesn't, it'll be treated as an immutable RefObject.
When you do useRef<HTMLInputElement>(null), you're hitting that case, since T is specified as HTMLInputElement, and null is inferred as HTMLInputElement | null.
You can fix this by doing:
useRef<HTMLInputElement | null>(null)
Then T is HTMLInputElement | null, which matches the type of the first argument, so you hit the first override and get a mutable ref instead.
as key.
You can use it like this for input component.
const inputRef = useRef() as MutableRefObject<HTMLInputElement>;
I came to this question by searching how to type useRef with Typescript when used with setTimeout or setInterval. The accepted answer helped me solve that.
You can declare your timeout/interval like this
const myTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
And to clear it and set it again, you do it as usual:
const handleChange = () => {
if (myTimeout.current) {
clearTimeout(myTimeout.current)
}
myTimeout.current = setTimeout(() => {
doSomething()
}, 500)
}
The typing will work both if you're running in Node or in a Browser.

Resources