I’ve been building Node.js applications for years, and I keep running into the same frustrating problem. I’ll spend hours carefully defining TypeScript interfaces, feeling confident that my code is type-safe. Then, at 2 AM, the production alert goes off because someone sent a string where a number should be, or an API returned a null value for a required field. TypeScript checked my code at compile time, but it couldn’t protect me from the messy, unpredictable data of the real world. That’s why I started combining TypeScript with Joi. Let me show you how this combination creates a safety net that catches errors before they crash your application.
Think of TypeScript as your blueprint and Joi as the building inspector. The blueprint tells you what the structure should look like, but the inspector makes sure the actual construction matches the plan. When data comes from an HTTP request, a database, or a third-party API, Joi is that inspector, validating everything before it enters your application’s logic.
Why does this matter so much? Because TypeScript types disappear when your code runs. They’re a development-time tool. A client can still send { "age": "twenty-five" } to your API, even if your User interface expects age to be a number. Without runtime checks, that string will slip through, causing unexpected behavior or crashes deeper in your codebase.
So how do we make them work together? Let’s start with a basic example. Imagine we’re building a user registration endpoint.
// First, define a TypeScript interface for our data
interface UserRegistration {
email: string;
password: string;
age: number;
}
// Then, create a matching Joi schema
import Joi from 'joi';
const userRegistrationSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
age: Joi.number().integer().min(13).max(120).required()
});
Now we have both a static type and a runtime validator. But here’s a question: wouldn’t it be better if we could generate one from the other? Maintaining two separate definitions feels like double the work.
You’re right to think that. This is where the integration gets clever. We can use the Joi schema to infer the TypeScript type, ensuring they never fall out of sync.
import Joi from 'joi';
// Define the schema first
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
birthYear: Joi.number().integer().min(1900).max(new Date().getFullYear())
});
// Infer the TypeScript type from the schema
type UserType = Joi.inferType<typeof userSchema>;
// Now UserType matches exactly what userSchema validates
function createUser(userData: UserType) {
// TypeScript knows the shape here
console.log(`Creating user: ${userData.username}`);
// And Joi can validate at runtime
const { error, value } = userSchema.validate(userData);
if (error) {
throw new Error(`Validation failed: ${error.message}`);
}
// Proceed with valid data
}
This approach means you define your validation rules once in Joi, and TypeScript automatically understands what shape the data should have. If you update the schema to add a new required field, TypeScript will immediately show errors anywhere you’re not providing that field.
But what about more complex scenarios? Real applications rarely deal with simple objects. We need to validate nested structures, arrays, and conditional logic.
Consider an e-commerce application where order validation depends on the payment method. For credit card payments, we need card details. For PayPal, we need an email. Joi handles this beautifully with conditional validation.
const orderSchema = Joi.object({
items: Joi.array().items(
Joi.object({
productId: Joi.string().required(),
quantity: Joi.number().integer().min(1).required()
})
).min(1).required(),
paymentMethod: Joi.string().valid('credit_card', 'paypal').required(),
// Credit card details are only required if paymentMethod is 'credit_card'
creditCard: Joi.when('paymentMethod', {
is: 'credit_card',
then: Joi.object({
number: Joi.string().creditCard().required(),
expiry: Joi.string().pattern(/^(0[1-9]|1[0-2])\/?([0-9]{2})$/).required(),
cvv: Joi.string().length(3).required()
}).required(),
otherwise: Joi.forbidden() // Must not be present for other methods
}),
// PayPal email is only required for PayPal payments
paypalEmail: Joi.when('paymentMethod', {
is: 'paypal',
then: Joi.string().email().required(),
otherwise: Joi.forbidden()
})
});
type OrderType = Joi.inferType<typeof orderSchema>;
The TypeScript type inferred from this schema will correctly make creditCard optional unless paymentMethod is 'credit_card', and the same for paypalEmail. This keeps your static types and runtime validation perfectly aligned, even for complex rules.
Where should you actually perform this validation? In a Node.js API, middleware is the perfect place. Here’s a practical implementation for an Express.js application:
import { Request, Response, NextFunction } from 'express';
import Joi from 'joi';
// Validation middleware factory
export const validateRequest = (schema: Joi.ObjectSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // Collect all errors, not just the first
stripUnknown: true // Remove fields not defined in schema
});
if (error) {
// Format errors nicely for the client
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
return res.status(400).json({
error: 'Validation failed',
details: errors
});
}
// Replace request body with validated, sanitized data
req.body = value;
next();
};
};
// In your route definition
app.post('/api/users',
validateRequest(userRegistrationSchema),
(req, res) => {
// By this point, req.body is guaranteed to match UserType
const userData: UserType = req.body;
// Safe to use without additional checks
}
);
This pattern gives you consistent validation across all endpoints with helpful error messages. Notice how we use stripUnknown: true? This is a security feature that removes any extra fields from the incoming data, preventing malicious or accidental data from reaching your business logic.
Have you considered what happens when validation passes? The data isn’t just checked; it can be transformed. Joi can convert strings to numbers, trim whitespace, set defaults, and more. These transformations happen during validation, so your application receives clean, standardized data.
const configSchema = Joi.object({
port: Joi.number().integer().min(1).max(65535).default(3000),
logLevel: Joi.string().valid('error', 'warn', 'info', 'debug').default('info'),
timeout: Joi.number().integer().min(1000).default(5000)
});
// Even if these come as strings from environment variables
const rawConfig = {
port: "8080", // String
timeout: "10000" // String
};
const { value: cleanConfig } = configSchema.validate(rawConfig);
// cleanConfig.port is now the number 8080
// cleanConfig.logLevel is "info" (default applied)
// cleanConfig.timeout is the number 10000
This is particularly useful for configuration management, where values often come as strings from environment variables or configuration files.
The combination of TypeScript and Joi has fundamentally changed how I build reliable applications. TypeScript catches my mistakes as I write code, while Joi protects my application from invalid data when it runs. Together, they provide confidence that my code will handle data correctly, reducing bugs and making debugging easier when problems do occur.
I encourage you to try this approach in your next Node.js project. Start with your most critical data structures—user input, API responses, configuration—and build from there. You’ll notice fewer runtime errors and spend less time debugging data-related issues.
What validation challenges have you faced in your projects? Have you found other ways to bridge the gap between static types and runtime data? I’d love to hear about your experiences in the comments below. If this approach helps you build more robust applications, please share this article with other developers who might benefit from it.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva