In Frontend development we tend to believe that all data will arrive perfectly structured, that APIs will always respond correctly, the user interactions will behave as expected. Unfortunately, the real examples are much messier. APIs can change or fail, users can provide invalid input, and network calls can hang indefinitely. There are many mission critical softwares out there like aerospace, military, banking systems and many more. If any of these systems fails it can have a huge impact, it would mean a lost satellite or a million dollar loss. I also can be a bit paranoid when I think about what could go wrong with my code. In this article I am going to show you techniques and concepts with React and TypeScript examples that you can use to make your code safer and error proof.
In this article
- TypeScript is Compile-Time, Not Runtime
- Prefer Explicit Errors Over Silent Failures
- Sanitize and Validate User Input
- Use Sensible Defaults for Props and State
- Guard Against Optional Data with Optional Chaining
- Prevent Side Effects
- Lock Down Critical Constants
- Set Timeouts and Abort Network Requests
- Wrap Dangerous Code
- Use Error Boundaries for Runtime Safety
1. TypeScript is Compile-Time, Not Runtime
One of the most common misconceptions when working with TypeScript is believing that type safety fully protects your application. TypeScript offers excellent compile-time guarantees, but it provides zero runtime validation. TypeScript can help you to catch many errors before your code ever runs for example when you are passing the wrong prop to a function, but all of this happens before runtime. It means that, once your code is transpiled to JavaScript, TypeScript completely disappears. This becomes especially dangerous when your application consumes external data, for example API responses and third-party libraries.
Let’s assume we are fetching user’s data from an external API endpoint:
type User = {
id: string;
name: string;
email: string;
}
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
}
At compile time, this seems fine. But the TypeScript type only tells the compiler what you expect. It does not validate the actual structure of data at runtime. If the API sends this:
{ "id": "abc", "username": "John" }
Your app could crash, or silently misbehave.
So, what is the solution? You need to treat all external data as untrusted until it’s validated.
The boundary is the moment external data enters your application (API responses, localStorage reads, user input). This is where you must validate and sanitize the data. You can do this process by yourself or you can use a schema validation library. My recommended schema validation libraries are: zod and yup.
The Same Example Using Zod
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
const result = UserSchema.safeParse(data);
if (!result.success) {
throw new Error('Invalid user data.');
}
return result.data;
}
2. Prefer Explicit Errors Over Silent Failures
The fail fast concept might be already familiar. This means your app should immediately throw explicit errors when something goes wrong instead of quietly continuing in an inconsistent or invalid state.
Let’s just go back to our fetchUser function, this function can do many thinks, but the most important is, it fetches the user’s using a user id.
async function fetchUser(userId: string): Promise<User> {
// log access
// check cache
// send analytics
// fetch user
// return user's data
}
It looks okay but what if the userId’s length is 0? We can assume that the endpoint will return a 404 error code. In that case we don’t need to wait for the API response since we are able to check the userId validity as soon as we start the function execution.
async function fetchUser(userId: string): Promise<User> {
if (!userId.length) {
throw new Error('userId is invalid.');
}
// log access
// check cache
// send analytics
// fetch user
// return user's data
}
With the failing fast philosophy we can detect the problem early and the app stops immediately with a clear message, enabling quick diagnosis.
3. Sanitize and Validate User Input
When building any application that interacts with users, handling user input securely and correctly is critical. User input is one of the most common vectors for bugs, security vulnerabilities, and data corruption. Therefore, sanitizing and validating user input must be a foundational practice. User input can come in many unpredictable forms. Even if your application restricts input types, users might bypass your webapp via API calls or browser dev tools. They also can paste malicious or malformed data into your input field. This problems can lead different kind of attacks like:
- Security vulnerabilities: Injection attacks like SQL Injection, Cross-Site Scripting (XSS), or command injection.
- Data corruption: Invalid or malformed data can break the app business logic or cause crashes.
- User experience issues: Without validation, users might submit incomplete or nonsensical data, leading to errors later.
Before we jump into the example there is one thing that we need to clarify. What is the difference between sanitization and validation?
- Validation is the process of checking whether the input meets certain criteria e.g., length, format, type, or allowed characters. It answers the question: Is this input valid?
- Sanitization is the process of cleaning or transforming the input to ensure it is safe for use in the application. It answers the question: Is this input safe?
Both are necessary steps and often go hand-in-hand.
Example of Validation
We can validate multiple things in an application. We can validate function input parameters, user inputs and entities as well. In this example I going to use zod to show how we can validate a user object to meet with the following criterias:
- Checking if an email address is properly formatted.
- Ensuring a password meets minimum complexity rules (length in that case).
- Ensuring numeric input is within an acceptable range.
import { z } from 'zod';
export const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().positive(),
});
export type User = z.infer<typeof UserSchema>
// We can use the new User type to parse the user response from the previous fetchUser(userId) function
Example of Sanitization
Sanitization also can be multiple things. The most basic example of sanitization is when we get an html and we would like to extract the text from it
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize('<b>hello there</b>');
Keep that in mind, this html content also can be a dangerous script tag.
Best practices
It is important to know where and when we should use these techniques. Best practice is to perform validation and sanitization as close to the input source as possible, ideally right when the data enters your system, whether it’s a form submission, an API request, or a file upload. This helps ensure that every downstream component works with clean and valid data.
I would like to also emphasise that, while it’s user-friendly to provide instant validation feedback in the browser, never trust frontend validation alone because it can be bypassed. Always validate and sanitize on the server-side or backend as well, ensuring robust defense-in-depth security.
4. Use Sensible Defaults for Props and State
Managing props and state correctly is crucial for building maintainable and predictable components. One of the best practices that often gets overlooked is providing sensible default values for props and state variables. Defaults make your components more resilient, easier to use, and reduce bugs. Imagine a component designed to render user profiles. If some props like username, avatarUrl, or bio are missing or undefined, the component might break or render an ugly, broken state. Without defaults, the component’s consumer must always provide all required props, increasing the chance of errors.
In many UI frameworks, props are how parent components pass data or configurations to children. You can set default prop values so that if the parent doesn’t provide a specific prop, the component uses the default instead.
In React it looks like this:
type ButtonProps = {
label?: string;
disabled?: boolean;
};
function Button({label="Click me", disabled=false}: ButtonProps) {
return (
<button disabled={disabled}>
{label}
</button>
);
}
This way, even if no label or disabled prop is passed, the button still renders with sensible defaults.
Defaults in States
Similarly, when initializing component state, providing initial default values is important. Without sensible initial state, components can end up in inconsistent or error-prone states.
For example:
const [count, setCount] = useState<number>(0);
const [items, setItems] = useState<string[]>([]);
const [user, setUser] = useState<User | null>(null);
Here, the defaults are logical starting points. An undefined or missing initial state could cause issues like trying to map over undefined or render null values improperly.
Advantages of Sensible Defaults
- Better Developer Experience: components are easier to use out-of-the-box.
- Reduced Bugs: missing props or uninitialized state is less likely to cause runtime errors.
- More Predictable Behavior: the component behaves consistently even with incomplete data.
- Improved Readability: defaults communicate the intended usage and fallback logic clearly.
When Not to Use Defaults
While defaults are helpful, sometimes a prop or state value is truly required, and falling back to a default might mask bugs or hide missing data problems. In such cases:
- Use TypeScript’s strict typing to enforce required props.
- Throw explicit errors or warnings if a critical prop is missing.
- Document your component’s expected inputs clearly.
5. Guard Against Optional Data with Optional Chaining
Dealing with optional or potentially undefined data is challenging. Whether it’s API responses, nested objects, or dynamic user input, our code frequently encounters values that might be missing or null. Accessing these without proper checks can lead to runtime errors that break the app. Optional chaining is a powerful language feature, introduced in recent years to help us handle such situations safely and elegantly. It allows us to access deeply nested properties without worrying about whether intermediate objects exist, reducing the need for verbose and error prone conditional checks.
Optional chaining uses the ?. operator to conditionally access properties or methods. If the value before ?. is null or undefined, the expression returns undefined instead of throwing an error.
An example without optional chaining:
const user = {
profile: {
name: "Alice",
address: {
city: "Wonderland",
},
},
};
const city = user && user.profile && user.profile.address && user.profile.address.city;
With optional chaining it look like this, simpler right?
const city = user?.profile?.address?.city;
In that case if any part of the chain is nullish (null or undefined), city will simply be undefined, preventing a crash.
6. Prevent Side Effects
When we are managing states, updates can make a significant difference in our app’s stability, maintainability, and performance. One crucial principle is immutability: avoiding direct mutations of state and instead producing new state objects whenever updates occur.
What Are Side Effects and Why Avoid Them?
A side effect happens when a function or operation changes something outside its own scope or has observable interactions with the outside world, such as modifying global variables, changing objects passed by reference, or altering the DOM.
In state management, side effects often manifest as direct mutations of the current state. This can cause unpredictable behavior, bugs, and subtle issues like:
- Components not re-rendering correctly because the state reference hasn’t changed.
- Difficulty in debugging because mutations happen silently.
- Challenges with undo/redo functionality or time-travel debugging.
- Hard-to-track data inconsistencies when multiple parts of the app share mutable state.
What Is Immutability?
Immutability means that once a data structure is created, it cannot be changed. Instead of modifying existing objects or arrays, you create new versions with the desired updates, leaving the original data intact.
For example, instead of changing an array in-place:
const numbers = [1, 2, 3];
numbers.push(4); // Mutates original array
You create a new array with the additional element:
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4]; // Creates new array
Why Favor Immutability in State Updates?
- Predictable State Changes: When state updates return new objects, you have clear, explicit state transitions, making your application easier to reason about.
- Optimized Rendering: Frameworks like React use shallow comparison to detect changes. Immutable updates ensure a new object reference, triggering necessary re-renders.
- Better Debugging and Testing: Immutability simplifies tracking the history of state changes, making debugging and implementing features like undo/redo more straightforward.
- Concurrency Safety: Immutable data structures reduce risks of race conditions in asynchronous or concurrent code.
While immutability is recommended, some low level optimizations or performance critical sections may use mutations carefully. However, this should be done only with full awareness of the consequences and appropriate safeguards.
7. Lock Down Critical Constants
Managing constants effectively is essential to ensuring our codebase remains robust, maintainable, and bug free. Critical constants, those values that underpin important logic or configuration should be “locked down” to prevent accidental modifications that can lead to unexpected behaviors or security vulnerabilities.
To lock down a constant means to safeguard it from being changed during the runtime of your application. It involves declaring the constant in a way that it is immutable, preventing reassignment or mutation. This ensures that once the value is set, it remains the same throughout the lifecycle of the app.
Why Are Critical Constants Important?
Critical constants are often used to represent:
- Configuration values (e.g., API endpoints, feature flags)
- Fixed thresholds or limits (e.g., maximum retry attempts)
- Fixed strings or identifiers (e.g., action types in Redux)
- Security-sensitive values (e.g., cryptographic keys or salts)
If these constants change unexpectedly, it can cause serious bugs, break application logic, or introduce security holes.
Risks of Mutable Constants
If critical constants are mutable or accidentally redefined, problems might include:
- Logic Errors: Code relying on fixed values may behave incorrectly if constants are changed.
- Hard to Track Bugs: Unintentional reassignments can cause intermittent or obscure bugs.
- Inconsistent Behavior: Parts of the app might assume different constant values, causing data inconsistencies.
In order to lock down variables we can use the const keyword, but what if the want to lock down an entire object?
const SETTINGS = { retryLimit: 5 };
SETTINGS.retryLimit = 10; // This is allowed!
To prevent mutation of objects or arrays we need to use either some Object methods(.freeze(), .seal()) or TypeScript’s useful keywords like readonly or as const.
type Config {
readonly retryLimit: number;
}
const config: Config = {
retryLimit: 5,
};
const SETTINGS = Object.freeze({
retryLimit: 5,
});
const config = {
retryLimit: 5,
} as const
8. Set Timeouts and Abort Network Requests
Handling network requests efficiently and reliably is hard. Whether we are fetching data from an API, sending user inputs, or communicating with backend services, network requests can be unpredictable. They may fail, hang indefinitely, or take longer than expected due to poor connectivity or server issues. This is where setting timeouts and aborting requests come into play.
Why Set Timeouts and Abort Network Requests?
Network requests that don’t resolve within a reasonable time can degrade the user experience and lead to resource wastage:
- Poor User Experience: If a request hangs, users may feel the app is frozen or unresponsive.
- Resource Consumption: Hanging requests consume memory and bandwidth unnecessarily.
- Error Handling: Without timeouts, it’s difficult to recover gracefully from network failures.
- Security: Persistent open connections may expose your app to certain vulnerabilities.
How to Set Timeouts for Network Requests?
There is native approach to set timeouts for network requests using the AbortController class with fetch. In this example I would like to show you a simpler solution using axios:
import axios from 'axios';
axios.get('https://api.example.com/data', { timeout: 5000 })
.then(response => {
// Handle success
})
.catch(error => {
if (error.code === 'ECONNABORTED') {
console.error('Request timed out');
} else {
console.error(error);
}
});
Axios will automatically abort the request if it takes longer than the specified timeout.
Some recommendations for setting timeouts:
- Choose Reasonable Timeout Durations: Too short timeouts may cancel legitimate slow requests; too long timeouts may hurt responsiveness.
- Use Retry Logic Wisely: Combine timeouts with retry mechanisms to handle transient network errors.
- Inform the User: Show meaningful messages or UI feedback when a request times out.
- Cleanup After Abort: Always clear timeouts or any associated resources after aborting to prevent memory leaks.
- Abort Unneeded Requests: Cancel requests that are no longer needed, for example, when a user navigates away from a page.
9. Wrap Dangerous Code
Handling user inputs and external data safely is critical to maintaining the security and stability. Parsing input data, whether it comes from users, third-party APIs, or external files can introduce a range of vulnerabilities if not done carefully. These inputs are often unpredictable and potentially dangerous, capable of causing injection attacks, corrupting data, or crashing your application.
Common Dangerous Input Scenarios
- JSON Parsing: User supplied JSON strings might contain malicious payloads or be malformed.
- HTML/JS Injection: User inputs that are directly inserted into the DOM can cause XSS vulnerabilities.
- Command Injection: Inputs used in shell commands or database queries can be exploited.
- File Uploads: Uploaded files might contain harmful content or malformed metadata.
I already showed some examples for sanitization and object parsing, in this example let me show you how you can parse JSONs safely using a custom validator:
function safeParseJSON<T>(jsonString: string, validator: (obj: unknown) => obj is T): T | null {
try {
const parsed = JSON.parse(jsonString);
if (validator(parsed)) {
return parsed;
}
console.error('Validation failed.');
return null;
} catch (error) {
console.error(error);
return null;
}
}
Here, the function attempts to parse JSON and then validates the result with a type guard, ensuring only safe data proceeds.
Handling Parsing Errors Gracefully
Always anticipate that parsing might fail. Use try-catch blocks or validation result patterns to handle errors gracefully without crashing your app. Provide clear feedback to users when inputs are invalid, and log errors for diagnostics.
10. Use Error Boundaries for Runtime Safety
Runtime errors in components can unexpectedly crash the entire UI or lead to inconsistent states. Without proper handling, these errors can degrade user experience, causing frustration and lost trust. React’s built-in solution for managing such errors is the concept of Error Boundaries, a powerful way to catch JavaScript errors anywhere in the component tree, log them, and display a fallback UI instead of crashing the whole app.
What Are Error Boundaries?
Error Boundaries are React components that catch JavaScript errors during rendering, in lifecycle methods, and in constructors of their child components. They act as a safety net, preventing an error in one part of your app from bringing down the entire interface.
Technically, an Error Boundary is any component that implements the lifecycle methods:
getDerivedStateFromError(error)componentDidCatch(error, info)
These allow the component to capture the error and update the UI accordingly.
Why Use Error Boundaries?
- Prevent Whole-App Crashes: Without Error Boundaries, an uncaught error bubbles up and unmounts the entire React component tree, blanking the UI.
- Improve User Experience: Instead of a broken or blank screen, users see a message or fallback UI.
- Better Error Reporting: Errors caught in boundaries can be logged to external services for diagnostics.
- Graceful Recovery: You can provide options to retry, reload, or navigate elsewhere, helping users recover smoothly.
How to implement Error Boundaries?
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
fallback: ReactNode;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
// Update state so the next render shows the fallback UI
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// You can log the error to an external service here
console.error('Uncaught error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
// Render fallback UI
return this.props.fallback;
}
return this.props.children;
}
}
Then wrap your components with <ErrorBoundary> to catch errors locally:
<ErrorBoundary fallback={<h1>Something went wrong.</h1>}>
<MyComponent />
</ErrorBoundary>
Best Practices for Using Error Boundaries
- Use Multiple Boundaries: Instead of wrapping the entire app in one boundary, consider multiple smaller ones to isolate failures and keep most of the UI running.
- Provide Meaningful Fallback UIs: Use fallback components that explain the problem and offer recovery options (e.g., retry buttons, navigation links).
- Log Errors Effectively: Integrate with services like Sentry, LogRocket, or your own logging backend to track errors in production.
- Combine with TypeScript: Even though TypeScript helps catch many bugs at compile time, runtime errors can still happen, so Error Boundaries remain essential.
- Handle Asynchronous Errors: For errors in async code or hooks, consider complementary solutions like useErrorHandler hooks or try-catch blocks.
Limitations and Things to Remember
- Error Boundaries do not catch errors inside event handlers, asynchronous code, or errors thrown in the Error Boundary itself.
- They also don’t catch errors from server-side rendering or errors thrown in React lifecycle methods like
getDerivedStateFromError.
Conclusion
By embracing these 10 safety-first practices in TypeScript and React, you move beyond just “making it work”, you build code that’s robust, predictable, and easier to maintain. From validating input to handling errors gracefully, each technique helps prevent bugs before they happen. Safety isn’t a luxury, it’s how great software is made.