How to validate CSV data in JavaScript: email, phone, and custom rules
Learn to validate CSV files in JavaScript with email validation, international phone numbers, and custom rules. Includes per-row error reporting and TypeScript examples.

Importing user data from CSV files sounds straightforward until you discover row 847 has an invalid email, row 1,203 is missing a phone number, and half your file uses inconsistent date formats. Without validation, bad data silently slips into your database.
This tutorial covers how to validate CSV data in JavaScript, focusing on email validation, international phone number validation, custom rules, and per-row error reporting. You'll get complete, runnable TypeScript code that you can drop into any React or Node.js project.
Prerequisites
- Node.js 18+
- React 18+ (for the React example)
- TypeScript 5+ (optional but recommended)
What You'll Build
A CSV validation pipeline that:
- Parses CSV files with PapaParse
- Validates email addresses using battle-tested libraries
- Validates international phone numbers with libphonenumber-js
- Reports errors per row with exact line numbers
- Provides TypeScript types for type safety
Step 1: Install dependencies
npm install papaparse validator libphonenumber-js zod
npm install -D @types/papaparse @types/validatorLibrary versions verified (January 2026):
| Library | Version |
|---|---|
| PapaParse | 5.5.3 |
| validator | latest |
| libphonenumber-js | 1.12.33 |
| Zod | 4.3.5 |
Step 2: Define TypeScript types for validation results
Before writing validation logic, define the types that structure your error reporting. This makes your validation results predictable and easy to consume in your UI.
interface ValidationError {
row: number;
column: string;
value: string;
message: string;
}
interface ValidationResult<T> {
valid: boolean;
validRows: T[];
invalidRows: Array<{ row: number; data: Record<string, unknown> }>;
errors: ValidationError[];
}This structure provides:
- Row numbers that match the original CSV (accounting for the header row)
- Column names so users know exactly which field failed
- The invalid value for debugging
- Human-readable error messages
Step 3: Parse CSV with PapaParse
PapaParse handles CSV parsing with automatic delimiter detection, header parsing, and error handling for malformed data.
import Papa from 'papaparse';
interface ParsedCSV {
data: Record<string, unknown>[];
headers: string[];
parseErrors: Papa.ParseError[];
}
function parseCSV(file: File): Promise<ParsedCSV> {
return new Promise((resolve, reject) => {
Papa.parse(file, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
complete: (results) => {
resolve({
data: results.data as Record<string, unknown>[],
headers: results.meta.fields || [],
parseErrors: results.errors
});
},
error: (error) => {
reject(new Error(`CSV parsing failed: ${error.message}`));
}
});
});
}Key configuration options:
header: truetreats the first row as column headersdynamicTyping: trueconverts numeric strings to numbersskipEmptyLines: trueignores blank rows (a common source of validation errors)
PapaParse error types you may encounter:
MissingQuotes- Unclosed quote in a fieldUndetectableDelimiter- Cannot determine the delimiterTooFewFields- Row has fewer fields than the headerTooManyFields- Row has more fields than the header
Step 4: Validate email addresses
Email validation is more complex than a simple regex. Simple patterns like /^\S+@\S+\.\S+$/ reject valid addresses such as user+tag@example.com or "unusual"@example.com.
Option A: Using validator.js (recommended)
import validator from 'validator';
function validateEmail(email: string): { valid: boolean; error?: string } {
if (!email || email.trim() === '') {
return { valid: false, error: 'Email is required' };
}
if (!validator.isEmail(email)) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true };
}The validator.isEmail() function handles edge cases including:
- Display names in emails
- UTF-8 local parts
- IP domain addresses
- TLD validation
Option B: RFC 5322 compliant regex
If you prefer avoiding dependencies, this regex covers approximately 99% of valid email addresses:
const emailRegex = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
function validateEmailRegex(email: string): { valid: boolean; error?: string } {
if (!email || email.trim() === '') {
return { valid: false, error: 'Email is required' };
}
if (!emailRegex.test(email)) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true };
}Step 5: Validate international phone numbers
Phone number validation requires country context. A 10-digit number is valid in the US but not in Germany. libphonenumber-js solves this with Google's phone metadata (at 145 kB, much smaller than Google's original 550 kB library).
import { parsePhoneNumberFromString, isValidPhoneNumber, CountryCode } from 'libphonenumber-js';
interface PhoneValidationResult {
valid: boolean;
error?: string;
formatted?: string;
country?: string;
}
function validatePhone(
phone: string,
defaultCountry: CountryCode = 'US'
): PhoneValidationResult {
if (!phone || phone.trim() === '') {
return { valid: false, error: 'Phone number is required' };
}
// Try parsing with the default country
const phoneNumber = parsePhoneNumberFromString(phone, defaultCountry);
if (!phoneNumber) {
return { valid: false, error: 'Could not parse phone number' };
}
if (!phoneNumber.isValid()) {
return { valid: false, error: 'Invalid phone number for the detected country' };
}
return {
valid: true,
formatted: phoneNumber.format('E.164'), // +14155552671 format for storage
country: phoneNumber.country
};
}Why E.164 format for storage:
E.164 is the international standard (+14155552671). Store phone numbers in this format, then display in local format ((415) 555-2671) as needed.
Handling numbers without country codes:
// BAD: No country context - this will fail
isValidPhoneNumber('5551234567'); // false
// GOOD: Provide default country
isValidPhoneNumber('5551234567', 'US'); // true if valid US number
// BEST: Use E.164 format with country code
isValidPhoneNumber('+15551234567'); // trueStep 6: Create custom validation rules
Beyond email and phone, you'll often need custom rules for dates, required fields, uniqueness, or cross-column validation.
type ValidatorFn = (value: unknown, row: Record<string, unknown>) => { valid: boolean; error?: string };
interface ColumnRule {
column: string;
validators: ValidatorFn[];
}
// Required field validator
const required: ValidatorFn = (value) => {
if (value === null || value === undefined || value === '') {
return { valid: false, error: 'Field is required' };
}
return { valid: true };
};
// Date range validator
function dateRange(minDate: Date, maxDate: Date): ValidatorFn {
return (value) => {
const date = new Date(value as string);
if (isNaN(date.getTime())) {
return { valid: false, error: 'Invalid date format' };
}
if (date < minDate || date > maxDate) {
return { valid: false, error: `Date must be between ${minDate.toISOString().split('T')[0]} and ${maxDate.toISOString().split('T')[0]}` };
}
return { valid: true };
};
}
// Conditional validation: if country is US, require state
function conditionalRequired(dependsOn: string, expectedValue: unknown): ValidatorFn {
return (value, row) => {
if (row[dependsOn] === expectedValue && !value) {
return { valid: false, error: `Required when ${dependsOn} is ${expectedValue}` };
}
return { valid: true };
};
}
// Minimum length validator
function minLength(min: number): ValidatorFn {
return (value) => {
if (typeof value === 'string' && value.length < min) {
return { valid: false, error: `Must be at least ${min} characters` };
}
return { valid: true };
};
}Step 7: Build the complete validation pipeline
Now combine parsing, header validation, and field validation into a complete pipeline with per-row error reporting.
import Papa from 'papaparse';
import validator from 'validator';
import { parsePhoneNumberFromString, CountryCode } from 'libphonenumber-js';
interface ValidationError {
row: number;
column: string;
value: string;
message: string;
}
interface ValidationResult<T> {
valid: boolean;
validRows: T[];
invalidRows: Array<{ row: number; data: Record<string, unknown> }>;
errors: ValidationError[];
headerErrors: string[];
}
interface ValidatorConfig {
column: string;
required?: boolean;
type?: 'email' | 'phone' | 'string' | 'number';
phoneCountry?: CountryCode;
custom?: (value: unknown, row: Record<string, unknown>) => { valid: boolean; error?: string };
}
function validateCSV<T>(
file: File,
expectedHeaders: string[],
validators: ValidatorConfig[]
): Promise<ValidationResult<T>> {
return new Promise((resolve) => {
const validRows: T[] = [];
const invalidRows: Array<{ row: number; data: Record<string, unknown> }> = [];
const errors: ValidationError[] = [];
const headerErrors: string[] = [];
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
// Step 1: Validate headers
const actualHeaders = results.meta.fields || [];
const missingHeaders = expectedHeaders.filter(h => !actualHeaders.includes(h));
if (missingHeaders.length > 0) {
headerErrors.push(`Missing required columns: ${missingHeaders.join(', ')}`);
}
// Step 2: Validate each row
results.data.forEach((row: unknown, index: number) => {
const rowData = row as Record<string, unknown>;
const rowNumber = index + 2; // +1 for header, +1 for 0-index
const rowErrors: ValidationError[] = [];
validators.forEach((config) => {
const value = rowData[config.column];
const stringValue = String(value ?? '');
// Required check
if (config.required && (!value || stringValue.trim() === '')) {
rowErrors.push({
row: rowNumber,
column: config.column,
value: stringValue,
message: `${config.column} is required`
});
return;
}
// Skip further validation if empty and not required
if (!value || stringValue.trim() === '') {
return;
}
// Type-specific validation
if (config.type === 'email') {
if (!validator.isEmail(stringValue)) {
rowErrors.push({
row: rowNumber,
column: config.column,
value: stringValue,
message: 'Invalid email format'
});
}
} else if (config.type === 'phone') {
const phone = parsePhoneNumberFromString(stringValue, config.phoneCountry || 'US');
if (!phone || !phone.isValid()) {
rowErrors.push({
row: rowNumber,
column: config.column,
value: stringValue,
message: 'Invalid phone number'
});
}
} else if (config.type === 'number') {
if (isNaN(Number(stringValue))) {
rowErrors.push({
row: rowNumber,
column: config.column,
value: stringValue,
message: 'Must be a number'
});
}
}
// Custom validation
if (config.custom) {
const result = config.custom(value, rowData);
if (!result.valid) {
rowErrors.push({
row: rowNumber,
column: config.column,
value: stringValue,
message: result.error || 'Validation failed'
});
}
}
});
if (rowErrors.length > 0) {
errors.push(...rowErrors);
invalidRows.push({ row: rowNumber, data: rowData });
} else {
validRows.push(rowData as T);
}
});
resolve({
valid: errors.length === 0 && headerErrors.length === 0,
validRows,
invalidRows,
errors,
headerErrors
});
}
});
});
}Step 8: Usage example
interface ContactRow {
name: string;
email: string;
phone: string;
company?: string;
}
const file = document.querySelector<HTMLInputElement>('#csv-input')?.files?.[0];
if (file) {
const result = await validateCSV<ContactRow>(
file,
['name', 'email', 'phone'], // Required headers
[
{ column: 'name', required: true },
{ column: 'email', required: true, type: 'email' },
{ column: 'phone', required: true, type: 'phone', phoneCountry: 'US' },
{
column: 'company',
custom: (value) => {
if (value && String(value).length > 100) {
return { valid: false, error: 'Company name too long (max 100 characters)' };
}
return { valid: true };
}
}
]
);
console.log(`Valid rows: ${result.validRows.length}`);
console.log(`Invalid rows: ${result.invalidRows.length}`);
if (result.errors.length > 0) {
console.log('Validation errors:');
result.errors.forEach(e => {
console.log(` Row ${e.row}, ${e.column}: ${e.message} (value: "${e.value}")`);
});
}
}Complete React example
Here is a complete React component with file upload, validation, and error display.
import { useState, useCallback } from 'react';
import Papa from 'papaparse';
import validator from 'validator';
import { parsePhoneNumberFromString } from 'libphonenumber-js';
interface ValidationError {
row: number;
column: string;
value: string;
message: string;
}
interface ValidationResult {
validCount: number;
invalidCount: number;
errors: ValidationError[];
headerErrors: string[];
}
export function CSVValidator() {
const [result, setResult] = useState<ValidationResult | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsProcessing(true);
setResult(null);
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
const errors: ValidationError[] = [];
const headerErrors: string[] = [];
let validCount = 0;
let invalidCount = 0;
// Check for required headers
const headers = results.meta.fields || [];
const required = ['name', 'email', 'phone'];
const missing = required.filter(h => !headers.includes(h));
if (missing.length > 0) {
headerErrors.push(`Missing columns: ${missing.join(', ')}`);
}
// Validate each row
results.data.forEach((row: unknown, index: number) => {
const data = row as Record<string, string>;
const rowNumber = index + 2;
const rowErrors: ValidationError[] = [];
// Validate name
if (!data.name?.trim()) {
rowErrors.push({
row: rowNumber,
column: 'name',
value: data.name || '',
message: 'Name is required'
});
}
// Validate email
if (!data.email?.trim()) {
rowErrors.push({
row: rowNumber,
column: 'email',
value: data.email || '',
message: 'Email is required'
});
} else if (!validator.isEmail(data.email)) {
rowErrors.push({
row: rowNumber,
column: 'email',
value: data.email,
message: 'Invalid email format'
});
}
// Validate phone
if (!data.phone?.trim()) {
rowErrors.push({
row: rowNumber,
column: 'phone',
value: data.phone || '',
message: 'Phone is required'
});
} else {
const phone = parsePhoneNumberFromString(data.phone, 'US');
if (!phone || !phone.isValid()) {
rowErrors.push({
row: rowNumber,
column: 'phone',
value: data.phone,
message: 'Invalid phone number'
});
}
}
if (rowErrors.length > 0) {
errors.push(...rowErrors);
invalidCount++;
} else {
validCount++;
}
});
setResult({ validCount, invalidCount, errors, headerErrors });
setIsProcessing(false);
},
error: () => {
setResult({
validCount: 0,
invalidCount: 0,
errors: [],
headerErrors: ['Failed to parse CSV file']
});
setIsProcessing(false);
}
});
}, []);
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h2>CSV Validator</h2>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
disabled={isProcessing}
style={{ marginBottom: '20px' }}
/>
{isProcessing && <p>Processing...</p>}
{result && (
<div>
<div style={{
padding: '15px',
marginBottom: '20px',
backgroundColor: result.errors.length === 0 ? '#d4edda' : '#f8d7da',
borderRadius: '4px'
}}>
<strong>Results:</strong> {result.validCount} valid, {result.invalidCount} invalid
</div>
{result.headerErrors.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<h3>Header Errors</h3>
<ul>
{result.headerErrors.map((err, i) => (
<li key={i} style={{ color: '#dc3545' }}>{err}</li>
))}
</ul>
</div>
)}
{result.errors.length > 0 && (
<div>
<h3>Validation Errors ({result.errors.length})</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#f8f9fa' }}>
<th style={{ padding: '8px', border: '1px solid #dee2e6', textAlign: 'left' }}>Row</th>
<th style={{ padding: '8px', border: '1px solid #dee2e6', textAlign: 'left' }}>Column</th>
<th style={{ padding: '8px', border: '1px solid #dee2e6', textAlign: 'left' }}>Value</th>
<th style={{ padding: '8px', border: '1px solid #dee2e6', textAlign: 'left' }}>Error</th>
</tr>
</thead>
<tbody>
{result.errors.slice(0, 20).map((error, i) => (
<tr key={i}>
<td style={{ padding: '8px', border: '1px solid #dee2e6' }}>{error.row}</td>
<td style={{ padding: '8px', border: '1px solid #dee2e6' }}>{error.column}</td>
<td style={{ padding: '8px', border: '1px solid #dee2e6', fontFamily: 'monospace' }}>
{error.value || '(empty)'}
</td>
<td style={{ padding: '8px', border: '1px solid #dee2e6', color: '#dc3545' }}>
{error.message}
</td>
</tr>
))}
</tbody>
</table>
{result.errors.length > 20 && (
<p style={{ marginTop: '10px', color: '#6c757d' }}>
Showing first 20 of {result.errors.length} errors
</p>
)}
</div>
)}
</div>
)}
</div>
);
}Common pitfalls
Simple email regex misses valid addresses
Problem: Regex like /^\S+@\S+\.\S+$/ rejects valid emails with plus signs, quotes, or unusual TLDs.
Solution: Use validator.isEmail() or the RFC 5322 compliant regex shown earlier. For critical applications, consider email verification services that check deliverability.
Phone validation without country context
Problem: isValidPhoneNumber('5551234567') returns false because there is no country context.
Solution: Either require E.164 format with country code (+15551234567) or provide a default country:
isValidPhoneNumber('5551234567', 'US'); // true if validCSV header mismatch
Problem: The uploaded CSV has different column names than expected, causing all validations to fail silently.
Solution: Validate headers before processing rows:
const expectedHeaders = ['name', 'email', 'phone'];
const actualHeaders = results.meta.fields;
const missingHeaders = expectedHeaders.filter(h => !actualHeaders?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required columns: ${missingHeaders.join(', ')}`);
}Empty rows cause spurious errors
Problem: Blank rows in the CSV trigger "required field" errors.
Solution: Use skipEmptyLines: true in PapaParse configuration.
Large files crash the browser
Problem: Loading a 100MB CSV into memory causes the browser tab to become unresponsive.
Solution: Use PapaParse streaming with the step callback to process one row at a time:
Papa.parse(file, {
header: true,
step: (row, parser) => {
// Process one row at a time
const result = validateRow(row.data);
if (!result.valid) {
errors.push(result.error);
}
// Optionally pause/abort for severe issues
if (errors.length > 1000) {
parser.abort();
}
},
complete: () => {
console.log('Validation complete');
}
});Encoding issues corrupt special characters
Problem: Names like "Muller" appear as "Müller" after parsing.
Solution: PapaParse auto-detects UTF-8 BOM. For files without BOM, ensure the source exports as UTF-8, or explicitly set the encoding:
Papa.parse(file, {
encoding: 'UTF-8',
// ...other options
});Alternative: Zod-based validation
If you prefer schema-first validation, Zod provides excellent TypeScript integration:
import { z } from 'zod';
import Papa from 'papaparse';
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
});
type Contact = z.infer<typeof contactSchema>;
function validateWithZod(file: File): Promise<{ valid: Contact[]; errors: Array<{ row: number; issues: z.ZodIssue[] }> }> {
return new Promise((resolve) => {
const valid: Contact[] = [];
const errors: Array<{ row: number; issues: z.ZodIssue[] }> = [];
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
results.data.forEach((row, index) => {
const result = contactSchema.safeParse(row);
if (result.success) {
valid.push(result.data);
} else {
errors.push({ row: index + 2, issues: result.error.issues });
}
});
resolve({ valid, errors });
}
});
});
}The zod-csv package extends this with CSV-specific helpers for header validation and type coercion.
The easier way: ImportCSV
Building robust CSV validation requires handling many edge cases: encoding issues, malformed data, large files, and user-friendly error display. ImportCSV provides this out of the box:
import { CSVImporter } from '@importcsv/react';
<CSVImporter
columns={[
{ name: 'name', required: true },
{ name: 'email', required: true, validate: 'email' },
{ name: 'phone', required: true, validate: 'phone' }
]}
onComplete={(data) => {
// Only valid, cleaned data reaches here
console.log(data);
}}
/>ImportCSV handles validation automatically, displays errors inline in the UI, and processes large files without freezing the browser.
Related posts
Wrap-up
CSV imports shouldn't slow you down. ImportCSV aims to expand into your workflow — whether you're building data import flows, handling customer uploads, or processing large datasets.
If that sounds like the kind of tooling you want to use, try ImportCSV .