Let's take a typical React Component that fetches data from an external source and updates the UI based on the network state
// Users.tsx
import { useState, useEffect } from "react";
export const REQUEST_STATUS = {
idle: "IDLE",
pending: "PENDING",
success: "SUCCESS",
error: "ERROR",
};
async function fetchUsers(): Promise<{ name: string; id: number }[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([{ name: "User 1", id: 1 }]);
}, 2000);
});
}
export function App() {
const [networkState, setNetworkState] = useState(REQUEST_STATUS.idle);
const [users, setUsers] = useState<{ name: string; id: number }[]>([]);
useEffect(() => {
setNetworkState(REQUEST_STATUS.pending);
// or setNetworkState("IDLE"); // valid
const getUsers = async () => {
const users = await fetchUsers();
setUsers(users);
setNetworkState(REQUEST_STATUS.success);
};
getUsers();
}, []);
return (
<div>
{networkState === "PENDING" ? (
<p>Fetching data...</p>
) : (
networkState === "SUCCESS" && (
<li>
{users?.map((user) => (
<ul key={user.id}> {user.name} </ul>
))}
</li>
)
)}
{networkState === "ERROR" && (
<div>An error occured while fetching users</div>
)}
</div>
);
}
At first glance, this code may seem perfectly fine and in fact, this will render the component without any error.
BUT...
Let's assume that the REQUEST_STATUS
is imported from some global file and used in our components like,
import { REQUEST_STATUS } from '../some/other/file'
and someone changed the statuses in the global file to something that breaks our Users.tsx
component. Boom!
export const REQUEST_STATUS = {
idle: "idle",
pending: "pending",
success: "success",
error: "error",
};
Typechecking in useState
While adding a type to the setNetworkState
is the ultimate fix to this problem, how do we do it without much change in the code?
First, let's infer the type of REQUEST_STATUS
in our User component,
Here the type of the keys idle
, pending
, success
, error
is inferred as a string
therefore using keyof
and typeof
would still result in string
as the overall type.
Detour - keyof
typeof
If the above syntax is confusing, let me break it down for you ;) Otherwise, skip to the next section.
typeof
Here TypeScript is going to be able to infer the type of your object for you. It's going to lift this object that you created from the value level to the type level.
so essentially typeof REQUEST_STATUS
will be inferred as { idle: string, pending: string, success: string, error: string }
So if we want to get a type of particular key in the object, we can do something like
type IDLE_TYPE = typeof REQUEST_STATUS["idle"]; // string
type PENDING_TYPE = typeof REQUEST_STATUS["pending"]; // string
type SUCCESS_TYPE = typeof REQUEST_STATUS["success"]; // string
type ERROR_TYPE = typeof REQUEST_STATUS["error"]; // string
keyof
The keyof
operator can be used to extract the keys of an object type.
keyof
can be used in conjunction with typeof
to create a union type of all keys in the object.
By extracting all the keys of the object type, we can combine these keywords to create union types
type REQUEST_TYPE = tyepof REQUEST_STATUS[keyof typeof REQUEST_STATUS] // string
// the above expression is equivalent to
type REQUEST_TYPE =
| { idle: string, pending: string, success: string, error: string }["idle"]
| { idle: string, pending: string, success: string, error: string }["pending"]
| { idle: string, pending: string, success: string, error: string }["success"]
| { idle: string, pending: string, success: string, error: string }["error"]
Back to rescue with "as const"
Now that we know how typeof
and keyof
can be used together to create union type from objects, let's see how "as const" fits in here.
The above type export type REQUEST_TYPE = tyepof REQUEST_STATUS[keyof typeof REQUEST_STATUS]
returns string
as the type, so if typing our setState
wouldn't solve our problem. This is because as long as we set a "string", TypeScript accepts it as a valid value.
import { REQUEST_TYPE, REQUEST_STATUS } from '../some/other/file'
const [networkState, setNetworkState] = useState<REQUEST_TYPE>(REQUEST_STATES.idle);
// ...somewhere in the code, we can still do
setNetworkState("idle"); // no error
setNetworkState("invalid_state"); // no error
setNetworkState(12) // Error as type "string" is inferred from initial state
Without further explanation, this is where "as const" assertion comes into play and makes it strongly typed. By simply adding as const
to the object we can make it as readonly and ensure that all properties are assigned the literal type instead of a more general version like string
or number
Combining with our previously learned trick we can create a union type of object values
Putting it together
// types
export const REQUEST_STATUS = {
idle: "IDLE",
pending: "PENDING",
success: "SUCCESS",
error: "ERROR",
} as const;
export type REQUEST_TYPES = typeof REQUEST_STATUS[keyof typeof REQUEST_STATUS];
// User.tsx
const [networkState, setNetworkState] = useState<REQUEST_TYPES>(REQUEST_STATUS.idle);
With this updated useState
, TypeScript compiler will throw an error for invalid statuses.
Conclusion
In conclusion, using "as const" in conjunction with typeof
and keyof
can help you create strongly typed union types for your React useState
hook, preventing potential bugs caused by unexpected value changes in your application. By implementing this approach, you'll ensure better type safety and maintainability in your codebase.
Happy coding!
Top comments (1)
Great article @cibi