Cracking the Code: TypeScript Tips & Tricks to Catapult Your React Application
Introduction
Every project that uses Typescript under the hood will contain some custom types that are being used across the application. As an application evolves or demands shift, it's important to manage all those types in accordance with DRY and KISS foundations. In the name of type safety, it might be tempting to type every single element within the codebase. However, it can be beneficial to stop for a minute and ask those 2 questions:
-
Why is there a need for a given type?
-
What is it needed for?
-
Where will it be used?
It's important to note that creating too many custom types can lead to the over engineered codebase. When one change request or a new feature requires updating x amount of custom types, it can make the codebase harder to maintain. This blog post will cover some useful tips and tricks to add to the cauldron of typescript magic to keep in mind when working with custom types.
The less typescript, the better
Excessive inclusion of types for every single data structure may seem like a good thing at first. However, having a lot of custom types can lead to an unnecessarily bloated and redundant codebase, where some types may be duplicated unnecessarily.
To better understand why often less is more it's best to show by example:
type User = {
id: string
username: string;
firstName: string;
lastName: string;
email: string;
dateOfBirth: Date;
};
type ParsedUser = {
id: string;
username: string;
dateOfBirth: string;
};
function parseUserData({ id, username, dateOfBirth }: User): ParsedUser {
return {
id,
username,
dateOfBirth: dateOfBirth.toDateString(),
};
}
At first glance, the above code looks good. Everything is type safe, but this code can definitely be improved. By following the three questions asked in the introduction:
-
Why is there a need for
ParsedUser
? - So that an array returned byparseUserData
could be used in different places in the codebase. -
What is
ParsedUser
needed for? - To have a simplified type ofUser
type. -
Where is
ParsedUser
used? - For example, in some components' prop types.
Doing one thing at a time. ParsedUser
is just a simplified version of the User
type, so it makes sense that when id
or username
on the User
type changes, ParsedUser
would also adapt.
This can be achieved by introducing typescript's built-in utility types. It's possible to reuse the User
type to construct the ParsedUser
type by using Pick
.
type User = {
id: string
username: string;
firstName: string;
lastName: string;
email: string;
dateOfBirth: Date;
};
type ParsedUser = Pick<User, "id" | "username"> & { dateOfBirth: string };
function parseUserData({ id, username, dateOfBirth }: User): ParsedUser {
return {
id,
username,
dateOfBirth: dateOfBirth.toDateString(),
};
}
With that small change, ParsedUser
and User
are "connected" so whenever username
or id
changes the ParsedUser
will also update.
The next thing to consider would be how to handle the return type of the parseUserData
function. If there ever will be a need to change the shape of ParsedUser
, e.g. adding a fullName
. With the current state of the code, every change to parseUserData
must be followed by updating the ParsedUser
type, which may not be ideal.
type User = {
id: string
username: string;
firstName: string;
lastName: string;
email: string;
dateOfBirth: Date;
};
type ParsedUser = Pick<User, "id" | "username"> & {
dateOfBirth: string;
fullName: string // added fullName type here
};
function parseUserData({ id, username, dateOfBirth, firstName, lastName }: User): ParsedUser {
return {
id,
username,
dateOfBirth: dateOfBirth.toDateString(),
fullName: `${firstName} ${lastName}` // so it can be added here as well.
};
}
Since ParsedUser
type is closely related to what parseUserData
returns, it's possible to use ReturnType
utility type from typescript and create this type like so:
type User = {
id: string
username: string;
firstName: string;
lastName: string;
email: string
};
function parseUserData({ id, username, dateOfBirth, firstName, lastName }: User): ParsedUser {
return {
id,
username,
fullName: `${firstName} ${lastName}`
};
}
type ParsedUser = ReturnType<typeof parseUserData>;
Two things have happened:
-
01
Typescript is smart enough to understand what types will be returned from a given function so it's possible to skip it in function declaration.
-
02
By using the
ReturnType
utility, theParsedUser
type will not only be dependent on theUser
type, but also on theparseUserData
function. When the shape returned byparseUserData
changes, theParsedUser
type will reflect that change as well.
As an addition, it is still possible to use this approach with async functions by introducing yet another utility type Awaited
, e.g.
async function getAllResources() {
return db.resource.findMany(); // returns an array of resources
}
type Resource = Awaited<ReturnType<typeof getAllResources>>[number];
Adding [number] to array type will tell Typescript to look on type of an item within that array.
As the above example presents, with less typescript the code is still type safe AND highly adaptable to any changes.
Utility types used in this example are just the tip of an iceberg. In typescript's documentation, there are way more utility types that can lead to enhancing the implementation of custom types and reducing the number of types needed in the application.
https://www.typescriptlang.org/docs/handbook/utility-types.html
Master union types
A type union is most commonly used when a given property can have more than one type or when creating a custom type. Basic type unions could look like this:
type Id = string | number;
However, type unions can also enhance the developer experience when working with React components. Again, best explained by example:
type Staus = "active" | "inactive" | "suspended" | "banned";
type UserCardProps = {
fullName: string;
role: "ADMIN" | "USER";
adminStaus: Status,
userStatus: Omit<Status, "suspended">
};
function UserCard(props: UserCardProps) {
return (
<>
{props.fullName} status: {props.role === 'ADMIN' ? props.adminStatus : props.userStatus}
</>
);
}
export default function UsersCards() {
return (
<>
{/* missing userAdmin error */}
<UserCard
fullName="Joe User"
role="USER"
userStatus="active"
/>
{/* missing userStatus error */}
<UserCard
fullName="Joe Admin"
role="ADMIN"
adminStatus="disabled"
/>
</>
);
}
This example is invalid because UserCard
requires to pass both userStatus
and adminStatus
no matter what user role is passed. A better experience would be to require passing the userStatus
only when the user's role is USER
and vice versa.
Possible fixes would be:
-
Make both
userStatus
andadminStatus
optional, but that may not be the intended behavior ofUserCard
. -
Create a separate
AdminCard
for handling admin stuff, but that can lead to code duplication, which could violate the DRY rule.
In this simple example, it would not be the end of the world to make adminStatus
and userStatus
optional. Oftentimes, components have more complex structures and making a lot of optional types can make it harder to understand what components require and when.
There is a better way of solving this issue with both type unions and typescript magic:
type Staus = "active" | "inactive" | "suspended" | "banned";
type BaseUser = { fullName: string }
type UserCardProps = BaseUser & (
| { role: "ADMIN", adminStatus: Status }
| { role: "USER", userStatus: Omit<Status, "suspended"> }
);
function UserCard(props: UserCardProps) {
return (
<div className="flex justify-between items-center">
<span className="font-bold">
{props.fullName}
</span>
<span>
status: {props.role === 'ADMIN' ? props.adminStatus : props.userStatus}
</span>
</div>
);
}
export default function UsersCards() {
return (
<>
{/* no longer missing the adminStatus, but still requires userStatus */}
<UserCard
fullName="Joe the User"
role="USER"
userStatus="active"
/>
{/* no longer missing the userStatus, but sill requires adminStatus */}
<UserCard
fullName="Joe the Admin"
role="ADMIN"
adminStatus="suspended"
/>
</>
);
}
By extracting the common properties (fullName
) to separate type and creating additional two separate types joined by union UserCard
now have props that will be required based on the provided role
prop.
Typescript will know that both types used in the union have a role property, so it will "merge" these into one type. In the end, the role will be of type "USER" | "ADMIN".
This behavior improves the developer experience, when working with this component because it requires different props depending on the user's role.
With great power comes great responsibility, so this pattern should be used wisely as it has some drawbacks:
-
it can make props type less readable
-
can be difficult to implement when there are two or more props that change the outcome overall props shape
Generic types for not a generic dev
Implementing generics enhances the flexibility and reusability of code segments within a codebase. This typescript is a fundamental tool that developers should frequently reach out for, becoming an essential tool in the toolkit of every developer working with typescript.
Generics can simply be used to create custom utility types e.g.
type PropsWithClassName<T extends object> = T & { className?: string }
type Id = string;
type WithId<T> = T & { id: Id }
type WithoutId<T extends { id: Id }> = Omit<T, "id">
```
But most commonly generics are being used to make some of the logic reusable like functions, hooks or services.
```ts
function swap(a: string, b: string): [string, string] {
return [b, a];
}
Function swap
can only be used with strings, but what if there is a requirement to swap numbers, or some other custom typed data? It's a good idea to make this function generic:
function swap<T>(a: T, b: T): [T, T] {
return [b, a];
}
Specifying return type [T, T] makes sense here, because this array will always consist of just two elements.
Now this function can swap not only numbers, but any data thrown in as arguments.
When working with React, generics can be used to create generic components f.g instead of creating Lists components for every single array the codebase has, it can be done with one generic component:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
};
function List<T extends { id: string }>({
items,
renderItem,
className,
}: PropsWithClassName<ListProps<T>>) {
return (
<ul className={className}>
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))}
</ul>
);
}
Thanks to adding <T extends { id: string }>, id which is used in item's key will be required part of item type.
The list can now be used with any type of data structure, which makes it a highly reusable component.
const users = [{ id: "u123", username: "Joe the User" }];
const invoices = [
{ id: "i456", title: "invoice for a computer", price: 5420.99, date: '2023-12-01' },
];
export default function DashboardPage() {
const renderUserItem = useCallback(
(user: typeof users[number]) => <span>{user.username}</span>,
[]
);
const renderInvoiceItem = useCallback(
(invoice: typeof invoices[number]) => <span>{invoice.title}</span>,
[]
);
return (
<>
<List items={users} renderItem={renderUserItem} />
<List items={invoices} renderItem={renderInvoiceItem} />
</>
);
}
Summary
TypeScript is approachable for beginners with its gradual typing and JavaScript compatibility, but mastering its advanced features like typing nuances and generics can be challenging. Despite the learning curve, the benefits of static typing lead to more reliable code, better collaboration, and reduced bugs. TypeScript's robust tooling and community support further enhance its appeal. The initial ease of use gives way to long-term advantages, making it a worthwhile choice for developers aiming for code quality and maintainability.
Typescript is easy enough for beginners to start their journey with static typing, but some more complex features (generics or type unions) can be hard to get around at first. Still, they can improve the maintainability and reusability of some parts of the codebase, so it's worth getting to know them.
I hope this blog post will ease the learning curve of the more complex topics of Typescript.