TypeScript for React Developers Series: Practical Tips and Real-World Scenarios
Switching to TypeScript can significantly enhance your React development experience by making your code more maintainable and scalable. With TypeScript's powerful type-checking, you'll catch bugs early, enjoy better code navigation, and find refactoring easier. In this article, we’ll dive into practical tips and real-world scenarios to help you effectively adopt TypeScript in your React projects. Whether you're new to TypeScript or looking to refine your skills, this guide has something for you. Let’s get started!
Prerequisites:
- Familiarity with basic TypeScript types
- Familiarity with React.js
- Reading the previous article is recommended
TypeScript provides several built-in utilities that help reduce type duplication, making your code cleaner and easier to maintain. Let’s explore some of these utilities and see how they can be applied in real-world scenarios.
For Objects:
TypeScript provides utilities to avoid duplicating type definitions for objects. Let's start with a basic object and apply some of these utilities!
type User = {
email: string;
first_name: string;
last_name: string;
username: string;
}
Pick vs. Omit:
Sometimes, we need to create a type similar to an existing one but with some properties added or removed. TypeScript provides the Omit
and Pick
utilities to make this easy:
type User = {
email: string;
first_name: string;
last_name: string;
username: string;
}
// Create new type without email and username:
type UserWithoutEmailUsername = Omit<User, 'email' | 'username'>
// Result:
// type UserWithoutEmailUsername = {
// last_name: string;
// first_name: string;
// }
In this example, we use Omit
to exclude email
and username
from the User
type.
type User = {
email: string;
first_name: string;
last_name: string;
username: string;
}
// Create new type with only email and username:
type UserWithEmailUsername = Pick<User, 'email' | 'username'>
// Result:
// type UserWithEmailUsername = {
// email: string;
// username: string;
// }
Here, we use Pick
to include only email
and username
from the User
type.
So, when should you use Pick
or Omit
? It depends on the type you want to manipulate and how you want to modify it.
Required vs. Partial:
Let's consider a real scenario: you're building an API application with endpoints for creating and updating resources. You need different types for each service function. Your code might look like this:
type User = {
email: string;
first_name: string;
last_name: string;
username: string;
}
function createUser(user: User) {
// ....
return {
email: user.email,
username: user.username,
id: 10
}
}
type OptionalUser = {
email?: string;
first_name?: string;
last_name?: string;
username?: string;
}
// You need an ID to query the user that you want to update
function updateUser(user: OptionalUser, id: number) {
// ....
return {
email: user.email,
username: user.username,
id: 10
}
}
But you can simplify this using Partial
, making your code cleaner:
type User = {
email: string;
first_name: string;
last_name: string;
username: string;
}
function createUser(user: User) {
// ....
return {
email: user.email,
username: user.username,
id: 10
}
}
function updateUser(user: Partial<User>, id: number) {
// ....
return {
email: user?.email,
username: user?.username,
id: 10
}
}
This makes your code easier to understand and more consistent.
What if you have a type with optional properties, and you want to clone this type but make all properties required? TypeScript provides the Required
utility:
type OptionalUser = {
email?: string;
first_name?: string;
last_name?: string;
username: string;
}
type RequiredUser = Required<OptionalUser>
Readonly:
When working with React, the props passed to a component shouldn't be modified within the component to enforce immutability. TypeScript provides the readonly
modifier to prevent reassignment of props, supporting this best practice. React props should be read-only.
import React from 'react';
// Define the props interface with readonly
interface UserCardProps {
readonly name: string;
readonly age: number;
}
const UserCard: React.FC<UserCardProps> = ({ name, age }) => {
// Attempting to modify name or age will cause a TypeScript error
// name = "New Name"; // Error: Cannot assign to 'name' because it is a read-only property.
return (
<div>
<h2>{name}</h2>
<p>Age: {age}</p>
</div>
);
};
export default UserCard;
Record:
The Record
utility is a powerful tool to create objects with specific key-value pairs, ensuring that all keys are present and that each key's value matches a defined type. Let's consider a real-world scenario where using the Record
utility type would be beneficial. Imagine you are developing an admin dashboard for a web application where users have different roles, such as admin
, editor
, and viewer
. Each role has specific permissions associated with it, such as the ability to create
, read
, update
, or delete
data. You can use the Record
type to define and manage these roles and their permissions in a type-safe way.
Define User Roles and Permissions:
// Define possible roles
type UserRole = 'admin' | 'editor' | 'viewer';
// Define possible permissions
type Permission = 'create' | 'read' | 'update' | 'delete';
// Use Record to map roles to their permissions
const rolePermissions: Record<UserRole, Permission[]> = {
admin: ['create', 'read', 'update', 'delete'],
editor: ['create', 'read', 'update'],
viewer: ['read'],
};
Creating a Function to Check Permissions:
function hasPermission(role: UserRole, permission: Permission): boolean {
const permissions = rolePermissions[role];
return permissions.includes(permission);
}
Using the Function in a Component:
import React from 'react';
type UserProps = {
role: UserRole;
};
const Dashboard: React.FC<UserProps> = ({ role }) => {
return (
<div>
<h1>Dashboard</h1>
{hasPermission(role, 'create') && (
<button>Create New Item</button>
)}
{hasPermission(role, 'read') && (
<div>Here are the items you can view.</div>
)}
{hasPermission(role, 'update') && (
<button>Update Item</button>
)}
{hasPermission(role, 'delete') && (
<button>Delete Item</button>
)}
</div>
);
};
export default Dashboard;
Example Usage of the Dashboard Component:
import React from 'react';
import Dashboard from './Dashboard';
const App: React.FC = () => {
return (
<div>
<h1>Admin View</h1>
<Dashboard role="admin" />
<h1>Editor View</h1>
<Dashboard role="editor" />
<h1>Viewer View</h1>
<Dashboard role="viewer" />
</div>
);
};
export default App;
NonNullable
Let's consider a scenario where you have a form for managing user profiles. This form can be used to create a new user or edit an existing one. We’ll use TypeScript to handle these scenarios safely, ensuring that certain fields are non-nullable when the form is submitted.
Defining the User Data Type:
interface User {
id?: number; // Optional during creation, required during editing
username: string;
email: string;
age?: number;
}
Form State Type:
type UserFormState = {
id?: number;
username: string | undefined;
email: string | undefined;
age?: number | undefined;
};
This type allows fields like username
and email
to be undefined
initially, especially when editing.
Using NonNullable
to Ensure Non-Nullable Fields
When submitting the form, you want to make sure that username
and email
are always defined:
type ValidUser = Omit<User, 'id'> & {
id?: number;
username: NonNullable<UserFormState['username']>;
email: NonNullable<UserFormState['email']>;
};
Here, ValidUser
ensures that username
and email
are non-nullable, making sure
these fields are always present when processing the form submission.
Form Component Implementation:
import React, { useState } from 'react';
const UserForm: React.FC<{ initialData?: User }> = ({ initialData }) => {
// Initialize form state with optional initial data
const [formState, setFormState] = useState<UserFormState>({
id: initialData?.id,
username: initialData?.username,
email: initialData?.email,
age: initialData?.age,
});
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormState((prevState) => ({ ...prevState, [name]: value }));
};
const handleSubmit = () => {
// Assert that username and email are non-nullable
const { id, username, email, age } = formState;
if (!username || !email) {
alert('Username and email are required.');
return;
}
const validUser: ValidUser = {
id,
username,
email,
age,
};
// Proceed with form submission logic
console.log('Submitting form:', validUser);
};
return (
<form onSubmit={(e) => e.preventDefault()}>
<div>
<label>
Username:
<input
type="text"
name="username"
value={formState.username || ''}
onChange={handleChange}
required
/>
</label>
</div>
<div>
<label>
Email:
<input
type="email"
name="email"
value={formState.email || ''}
onChange={handleChange}
required
/>
</label>
</div>
<div>
<label>
Age:
<input
type="number"
name="age"
value={formState.age || ''}
onChange={handleChange}
/>
</label>
</div>
<button type="submit" onClick={handleSubmit}>
Submit
</button>
</form>
);
};
export default UserForm;
For Functions:
TypeScript gives us many utilities to work with functions, allowing us to infer types and avoid duplicating type definitions. Let's dive into an example:
type User = {
email: string;
first_name: string;
last_name: string;
username: string;
}
function createUser(args: User) {
// ....
return {
id: 12,
email: args.email,
username: args.username
}
}
How can we infer the return type of this function?
We have two methods to get the return type of this function:
type returnTypeOfCreateUser = Omit<User, 'last_name' | 'first_name'>
This approach works, but it doesn't offer the flexibility or consistency needed if the return type of the function changes. To solve this, we can use ReturnType
to directly infer the type:
type returnTypeOfCreateUser2 = ReturnType<typeof createUser>
What about async functions?
async function createUser2(args: User) {
// ....
return {
id: 12,
email: args.email,
username: args.username
}
}
The answer is yes! You can combine Awaited
with ReturnType
to handle async functions:
type returnTypeOfCreateUser3 = Awaited<ReturnType<typeof createUser2>>
What if I want to get the type of parameters that a function from an external library accepts?
Imagine you're using a third-party library, and you want to wrap one of its functions. You can use the Parameters
utility to get the types of its parameters:
type paramsType = Parameters<typeof createUser> // type paramsType = [args: User]
The Parameters
utility returns an array of the parameter types that createUser
accepts!
Conclusion
TypeScript's utility types like ReturnType
, Parameters
, Partial
, Required
, Omit
, Pick
, Readonly
, Record
, and NonNullable
make it easier to write clean, maintainable, and type-safe React code. By using these utilities, you can handle real-world scenarios effectively, reduce errors, and enhance the overall quality of your projects. Start leveraging these powerful features to take your React development to the next level. For more detailed information and advanced use cases, check out additional resources like the TypeScript Handbook and the React Documentation.