How to use typescript generics constraints with useState - reactjs

I am learning Typescript and have a custom React hook similar to this:
import { useEffect, useState } from 'react';
type TypeOne = {
one: string;
};
type TypeTwo = {
two: number;
};
type TypeThree = {
three: {
some: string;
};
}
type AnyPropertyWithString = {
[index: string]: string | AnyPropertyWithString;
};
export function getContent(condition: string): Promise<AnyPropertyWithString> {
return Promise.resolve({ one: 'content' });
}
export default function useContent<T extends AnyPropertyWithString>(
initial: T,
condition: string
): T {
const [content, setContent] = useState<T>(initial);
useEffect(() => {
async function fetchData() {
const data = await getContent(condition);
setContent(data);
}
fetchData();
}, [condition]);
return content;
}
My intention is to provide different types of data to this hook initially and save it in a state. Then fetch new data on some condition and replace the state. I want to restrict the type provided to the hook to AnyPropertyWithString. I want to use generic because the types I will provide could have different properties and multiple levels. The return type should be the same as generic.
I expect to use it like this:
const one: TypeOne = { one: 'content' };
const two: TypeTwo = { two: 2 };
const three: TypeThree = { three: { some: '3' } }
const firstResult = useContent(one, 'condition'); // ok
const secondResult = useContent(two, 'condition'); // expected TS error
const thirdResult = useContent(three, 'condition'); // ok
However, when I try to setContent(data), I have an error:
Argument of type 'AnyPropertyWithString' is not assignable to parameter of type 'SetStateAction<T>'.
Type 'AnyPropertyWithString' is not assignable to type 'T'.
'AnyPropertyWithString' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'AnyPropertyWithString'.
I don't want to cast it to T as I've read that this is a bad practice.
I suspect the problem is that getContent returns the data of the type that can't be effectively matched against, but I thought that generic constraint would narrow down T to AnyPropertyWithString (which is returned by getContent).
I tried useState<T | AnyPropertyWithString>, but then I have a problem with the return type. I also tried different combinations of extends, but none worked.
Could you, please, explain why I have this error and how I can work around it if that's possible?
I really appreciate any help you can provide.

How is getContent supposed to know what T is, and then return data in the format of T?
When you do this:
const hookReturnValue = useContent({ foo: string }, 'condition')
Then the T type in useContent is { foo: string }, which is a subtype of AnyPropertyWithString. getContent returns an entirely different subtype of T.
So if this code ran, then the initial value would set T to { foo: string }, then the effect would run and you would save { one: string } to state, which is not a compatible type.
Then your code would do:
const hookReturnValue = useContent({ foo: string }, 'condition')
hookReturnValue.foo.toUpperCase() // crash when effect completes
This is what is meant by this error:
'AnyPropertyWithString' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'AnyPropertyWithString'.
It's unclear how getContent would work, but it would need to know what kind of data format to return at runtime, and you aren't passing it anything that would let it figure that out.
So if you figure out how getContent will return the same type as the initial argument type of the hook, then the answer will start reveal itself.

Related

Why does TypeScript not throw an error when a variable with wrong type is passed to useState?

Why does the TypeScript not throw an error when a variable is passed to useState? Is there a way I can still type check when passing a variable?
type BuildingNames = [
'farm',
'sawmill',
];
type BuildingType = { [n in BuildingNames[number]]?: number };
interface ITownContext {
buildings: BuildingType;
setBuildings: (buildings: BuildingType) => void;
}
interface TownProviderProps {
children: ReactNode;
}
const defaultValues = {
resourceBuildings: {
farm: 1,
asdfasdf: 5,
},
};
const TownContext = createContext<ITownContext>({} as ITownContext);
const TownProvider = ({ children }: TownProviderProps) => {
// no error
const [buildings, setBuildings] =
useState<BuildingType>(defaultValues.buildings);
// error occurs
const [buildingsDirect, setBuildingsDirect] =
useState<BuildingType>({ asdf: 1 });
return (
<TownContext.Provider
value={{
buildings,
setBuildings,
}}
>
{children}
</TownContext.Provider>
);
};
export { TownContext, TownProvider };
The difference you see between the 2 usages of useState is the effect of TypeScript excess property check, which triggers only when doing an assignment of an object literal (i.e. as opposed to assigning the reference of another variable, like defaultValues.buildings).
The fact that excess property check is not enforced when assigning the reference of another variable, enables a classic OOP usage of passing a subtype (here an object with more properties than strictly necessary).
Still, even when excess property check does not kick in, TS still checks for other errors: should one of the properties not match its type, it will be reported.
const defaultValues = {
buildings: {
farm: "string instead of number",
asdfasdf: 5,
},
};
useState<BuildingType>(defaultValues.buildings) // Types of property 'farm' are incompatible. Type 'string' is not assignable to type 'number'.
// ~~~~~~~~~~~~~~~~~~~~~~~
Playground Link

Typescript String enum type 'string' is not assignable to type

Link to Playground
Pretty simple, how do I avoid this compile error?
type ExampleClass = {
relevantValue: EXAMPLE_STRING_ENUM;
}
enum EXAMPLE_STRING_ENUM {
HELLO = 'hello',
}
const exampleArray: ExampleClass[] = [];
const mockData = {
relevantValue: 'hello'
}
exampleArray.push(mockData);
results in
Argument of type '{ relevantValue: string; }' is not assignable to
parameter of type 'ExampleClass'. Types of property 'relevantValue'
are incompatible.
Type 'string' is not assignable to type 'EXAMPLE_STRING_ENUM'.(2345)
In my head this should work. During writing code, I can have a switch/case statement depending on what a given field is, but the mockData comes from live, so of course it is not having an enum value, but directly the string value instead.
I thought since it is a string enum, typescript should know that the values can only be of type string?
I could just fix it by making a union type like this:
type ExampleClass = {
relevantValue: EXAMPLE_STRING_ENUM | string;
}
but this does not really solve the underlying problem, which I am still trying to fix.
Also, this 'fix' would lead to follow up errors like such:
exampleArray.forEach((e) => {
setFoo(e.relevantValue)
})
Argument of type 'string' is not assignable to parameter of type
'SetStateAction<EXAMPLE_STRING_ENUM | undefined>'.(2345)
EDIT: Solved it, by typing all values where the compiler cannot know whether the string provided really is matching one of the ENUM strings, so throwing the error made sense.
Updated code:
const [foo, setFoo] = React.useState<EXAMPLE_STRING_ENUM>();
type ExampleClass = {
relevantValue: string;
}
enum EXAMPLE_STRING_ENUM {
HELLO = 'hello',
}
const exampleArray: ExampleClass[] = [];
const mockData: ExampleClass = {
relevantValue: 'hello'
}
exampleArray.forEach((e) => {
setFoo(e.relevantValue as EXAMPLE_STRING_ENUM)
})
You need to add a guard to the mockData constant to let Typescript know that relevantValue is constrained to values in the enum:
const mockData: ExampleClass = {
relevantValue: EXAMPLE_STRING_ENUM.HELLO
}
If you don't do this then the following is allowed, which is why typescript errors in your example:
mockData.relevantValue = 'not an enum value';
playground

React Hooks useState initial empty array value in typescript

I'm trying to make typescript happy by giving the correct type. But I can't figure out the exact syntax.
export type CategoriesType = {
id: string;
title: string;
url: string;
courses: CoursesType[];
};
const [singleCategory, setSingleCategory] = useState<CategoriesType>([] as any);
useEffect(() => {
const categories = Categories;
for (const category of categories) {
setSingleCategory(category);
}
}, [singleCategory]);
return (
singleCategory.courses !== [] ? ...
)
This code works. But typescript complains about usage of any. I would prefer to avoid using any.
Other tries:
I get error Property 'courses' does not exist on type 'CategoriesType | []'
const [singleCategory, setSingleCategory] = useState<CategoriesType | []>([]);
Object is possibly 'undefined'.
const [singleCategory, setSingleCategory] = useState<CategoriesType>();
Set state like below.
const [singleCategory, setSingleCategory] = useState<CategoriesType[]>([])
Also in your return singleCategory.courses doesn't make any sense because singleCategory is array type not the object type. You should match your types for state definition and usage.

Typescript adding "| null" to return type of Promise.all

I have an async TS function that makes a request and casts the response data to a boolean and returns it, but in the calling function VS Code is telling me the return value is boolean | null when I make the call in Promise.all. Here's the code:
The function:
import apiAxios from "../apiAxios";
export default async function doesAssignmentHaveTakes(
assignmentId: number
): Promise<boolean> {
const response = await apiAxios.get(`/assignments/${assignmentId}/has-takes`);
return !!response.data;
}
And the caller:
import React, { FC, useState, useCallback } from "react";
import styled from "styled-components/macro";
import AssignmentForm, {
Props as AssignmentFormProps,
Value as AssignmentFormValue
} from "./AssignmentForm";
import useAsyncEffect from "../utils/useAsyncEffect";
import getAssignmentById from "../api/assignments/getAssignmentById";
import doesAssignmentHaveTakes from "../api/assignmentTakes/doesAssignmentHaveTakes";
interface Props extends AssignmentFormProps {
assignmentId: number;
onSubmit(value: Value): any;
}
export interface Value extends AssignmentFormValue {
assignmentId: number;
}
const EditAssignmentForm: FC<Props> = props => {
const { assignmentId, onSubmit, ...rest } = props;
const [showEditWarning, setShowEditWarning] = useState(false);
const [initialValue, setInitialValue] = useState<AssignmentFormValue | null>(
null
);
useAsyncEffect(
async isCancelled => {
const [fetchedAssignment, hasTakes] = await Promise.all([
getAssignmentById(assignmentId),
doesAssignmentHaveTakes(assignmentId)
]);
if (!fetchedAssignment) {
// TODO: Alert parent component?
return;
}
const value: Value = {
assignmentId: fetchedAssignment.id,
assignment: {
title: fetchedAssignment.title,
subTitle: fetchedAssignment.subTitle
},
sets: fetchedAssignment.sets
.map(set => ({
id: set.id,
index: set.index,
questions: set.questions
.map(question => ({
id: question.id,
index: question.index,
isPractice: question.isPractice,
questionText: question.questionText,
inputType: question.inputType,
questionImage: question.questionImage,
sampleResponseText: question.sampleResponseText,
sampleResponseImage: question.sampleResponseImage
}))
.sort((a, b) => a.index - b.index),
learningTarget: set.learningTarget,
isExampleCorrect: set.isExampleCorrect,
exampleImage: set.exampleImage,
character: set.character
}))
.sort((a, b) => a.index - b.index)
};
if (!isCancelled()) {
setInitialValue(value);
setShowEditWarning(hasTakes);
}
},
[assignmentId]
);
const handleSubmit = useCallback(
(value: AssignmentFormValue) => {
onSubmit({
...value,
assignmentId
});
},
[onSubmit, assignmentId]
);
if (!initialValue) {
// Loading...
return null;
}
return (
<AssignmentForm
{...rest}
initialValue={initialValue}
onSubmit={handleSubmit}
/>
);
};
export default styled(EditAssignmentForm)``;
The specific lines with the issue:
const [fetchedAssignment, hasTakes] = await Promise.all([
getAssignmentById(assignmentId),
doesAssignmentHaveTakes(assignmentId)
]);
And
setShowEditWarning(hasTakes);
The TS error:
TypeScript error in /Users/james/projects/math-by-example/client/src/components/EditAssignmentForm.tsx(71,28):
Argument of type 'boolean | null' is not assignable to parameter of type 'SetStateAction<boolean>'.
Type 'null' is not assignable to type 'SetStateAction<boolean>'. TS2345
69 | if (!isCancelled()) {
70 | setInitialValue(value);
> 71 | setShowEditWarning(hasTakes);
| ^
72 | }
73 | },
74 | [assignmentId]
And some screenshots of the error in VS Code
Why does TS add null to the resolved types of Promise.all?
The solution is to add as const to the array you pass to Promise.all.
Explanation
The problem is not with the typing of Promise.all or a bug in the compiler. The issue is what TypeScript does by default with an array. Consider this:
const q = [1, "a"];
The default type inference for q will be (string | number)[]. Even though you have a number as the first position, and a string as the second, TypeScript infers that all positions can be either a string or a number. If you want TypeScript to treat the array as a tuple and assign to each position the narrowest type possible, you can do:
const q = [1, "a"] as const;
TS will infer a type of readonly [1, "a"] for this array. So q can have only the number 1 in the first position and the string "a" in the second. (It is also readonly but that's a side issue here.) This was introduced in TypeScript 3.4.
Ok, what does this have to do with your case? When you pass your array to Promise.all TypeScript is using the kind of type inference I've shown in my first example. Promise.all sees an array in which each item can take the union of all values that the items can take. If you use as const then the inference will be like the second case I've shown above, and this will be reflected accordingly in the type that Promise.all gets. Again, there's no problem with Promise.all's typing. It works with what it gets: bad typing in, bad typing out.
Here's an illustration (also in the playground):
async function fa(): Promise<string | null> { return "foo"; }
async function fb(): Promise<boolean> { return true; }
async function main(): Promise<void> {
let a: string | null;
let b: boolean;
// Remove this "as const" and the code won't compile.
[a, b] = await Promise.all([fa(), fb()] as const);
console.log(a, b);
}
main();
This has been resolved from ts 3.9+ (release note), upgrade to 3.9 and you will not see this error.
The problem is with the type definitions for Promise.all, take a look. The return type of the .all always try to make a union of the array promises generic types.
Probably your other function getAssignmentById can return null, so the Promise.all will infer a return type of [something | null, boolean | null]. It is possibly a bug with the TS compiler, I am not sure. I made a playground with the same conditions to see the inference in practice, take a look at how it infers the generic types on Promise constructor, then remove the null from the return type of funcB and see the Promise.all type again... it behaves as expected.
Agree with Pedro's answer that Promise.all doesn't deal with different return types out of the box.
You can try declaring the return type of your Promise like this:
const [fetchedAssignment, hasTakes] = await Promise.all<string | null, boolean>([
getAssignmentById(assignmentId),
doesAssignmentHaveTakes(assignmentId)
]);

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

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.

Resources