Crafting Robust TypeScript Code: Error Handling Best Practices
- Exceptions (JavaScript): In vanilla JavaScript, exceptions are used to signal unexpected errors that disrupt normal program flow. You throw exceptions with
throw
and catch them withtry...catch
blocks. However, JavaScript doesn't enforce specific error types, making error handling less robust. - Errors (TypeScript): TypeScript introduces the
Error
class and allows custom error classes for more structured error handling. You can define error properties and types to provide richer context about the error. This static type checking helps catch potential issues during development.
Proper Use of Errors in TypeScript
Here are key principles for effective error handling in TypeScript:
Throw Errors for Unexpected Conditions:
- Use
throw
to signal exceptional circumstances that shouldn't occur in normal program execution. - Examples:
- Invalid user input (e.g., negative quantity)
- Network failures
- File system errors
- Don't throw errors for expected conditions (use type checking or validation instead).
- Use
Create Custom Error Classes:
- Extend the built-in
Error
class to create error types specific to your application domain. - Include properties to provide clear error information:
- Message (describing the error)
- Code (unique identifier for the error type)
- Stack trace (to pinpoint the error's origin)
- Example:
class InvalidAgeError extends Error { constructor(age: number) { super(`Age must be positive, received: ${age}`); this.code = 'INVALID_AGE'; } }
- Extend the built-in
Use Type Guards for Error Narrowing:
Provide Contextual Error Messages:
- Include informative messages in error objects to aid debugging and user feedback.
- Example:
throw new MyError('Failed to connect to database: ' + error.message);
Consider Alternative Error Handling Mechanisms:
- For asynchronous operations (like promises), use
async/await
or.catch()
methods for error handling. - In some cases, returning specific error objects or using the
never
type can signal errors more declaratively.
- For asynchronous operations (like promises), use
Benefits of Proper Error Handling
- Improved Code Maintainability: Clear error types and messages make code easier to understand and debug.
- Enhanced Robustness: Catching and handling errors gracefully prevents program crashes and unexpected behavior.
- Better User Experience: Providing informative error messages helps users understand issues and take corrective actions.
class InvalidAgeError extends Error {
constructor(age: number) {
super(`Age must be positive, received: ${age}`);
this.code = 'INVALID_AGE';
}
}
function validateAge(age: number): number {
if (age <= 0) {
throw new InvalidAgeError(age);
}
return age;
}
try {
const validatedAge = validateAge(-10); // This will throw
} catch (error) {
if (error instanceof InvalidAgeError) {
console.error(`Invalid age: ${error.message} (code: ${error.code})`);
} else {
// Handle other types of errors (e.g., parsing errors)
console.error("Unexpected error:", error);
}
}
Error Handling with Async/Await:
async function fetchData(url: string): Promise<string> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.statusText}`);
}
const data = await response.text();
return data;
} catch (error) {
console.error("Error fetching data:", error.message);
throw error; // Re-throw for further handling (optional)
}
}
(async () => {
try {
const data = await fetchData('https://example.com/api/data');
console.log("Fetched data:", data);
} catch (error) {
console.error("Error processing data:", error.message);
}
})();
Error Handling with never Type:
function divide(x: number, y: number): number {
if (y === 0) {
throw new Error("Division by zero"); // Or use never type for clarity:
// throw new Error("Division by zero") never;
}
return x / y;
}
try {
const result = divide(10, 0); // This will throw
} catch (error) {
console.error("Error:", error.message);
}
Discriminated unions leverage TypeScript's type system to represent successful results (data) and error conditions within a single union type. This promotes type safety and avoids the need for separate error objects.
Example:
type Result<T> = Success<T> | Failure;
interface Success<T> {
tag: "success";
data: T;
}
interface Failure {
tag: "failure";
message: string;
}
function validateAge(age: number): Result<number> {
if (age <= 0) {
return { tag: "failure", message: "Age must be positive" };
}
return { tag: "success", data: age };
}
const result = validateAge(-10);
if (result.tag === "success") {
console.log("Valid age:", result.data);
} else {
console.error("Error:", result.message);
}
Promises with .catch():
For asynchronous operations, promises provide a built-in mechanism for error handling. The .catch()
method allows you to handle errors gracefully within the asynchronous flow.
fetch('https://example.com/api/data')
.then(response => response.json())
.catch(error => {
console.error("Error fetching data:", error.message);
});
Result Monad (Functional Programming Approach):
In functional programming, the result monad is a pattern that encapsulates either a successful result or an error. This promotes code purity and simplifies error handling logic.
While TypeScript doesn't have a built-in result monad, you can create one using libraries like fp-ts
.
Choosing the Right Method:
- Discriminated unions: Ideal for synchronous code where you want type safety and clear separation of successful and error states.
- Promises with
.catch()
: Best suited for asynchronous code where you already have promises for handling the flow of data. - Result Monad: A good choice for functional programming paradigms with emphasis on code purity and immutability.
exception typescript