Blog
January 11, 2026

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.

14 mins read

How to validate CSV data in JavaScript: email, phone, and custom rules

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/validator

Library versions verified (January 2026):

LibraryVersion
PapaParse5.5.3
validatorlatest
libphonenumber-js1.12.33
Zod4.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: true treats the first row as column headers
  • dynamicTyping: true converts numeric strings to numbers
  • skipEmptyLines: true ignores blank rows (a common source of validation errors)

PapaParse error types you may encounter:

  • MissingQuotes - Unclosed quote in a field
  • UndetectableDelimiter - Cannot determine the delimiter
  • TooFewFields - Row has fewer fields than the header
  • TooManyFields - 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.

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'); // true

Step 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 valid

CSV 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.

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 .