Email Validator — TypeScript source
Validate email addresses one at a time or in bulk. Checks syntax, length limits, local-part and domain rules, plus-addressing, and IP-literal domains - all in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// RFC 5321/5322-inspired email validation. Pure, deterministic, never throws.
// errs on the side of practical deliverability (provider-friendly) while still
// recognising the legal-but-unusual forms (quoted local parts, IP-literal domains).
export interface EmailResult {
valid: boolean;
local?: string;
domain?: string;
normalized?: string;
reasons: string[];
warnings: string[];
}
const LOCAL_MAX = 64;
const DOMAIN_MAX = 253;
const TOTAL_MAX = 320;
// Characters permitted in an unquoted (atom) local part.
const LOCAL_CHARS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
/** Split an email into local + domain, honouring a quoted ("...") local part. */
function splitLocalDomain(
email: string,
): { local: string; domain: string; quoted: boolean } | null {
if (email.startsWith('"')) {
// Walk the quoted string; a backslash escapes the next byte.
let i = 1;
while (i < email.length) {
const ch = email[i];
if (ch === '\\') { i += 2; continue; }
if (ch === '"') break;
i += 1;
}
if (email[i] !== '"') return null; // unterminated quote
const at = i + 1;
if (email[at] !== '@') return null; // '@' must follow the closing quote
if (email.indexOf('@', at + 1) !== -1) return null; // stray '@' in domain
return { local: email.slice(0, at), domain: email.slice(at + 1), quoted: true };
}
const first = email.indexOf('@');
if (first === -1) return null;
if (email.indexOf('@', first + 1) !== -1) return null; // multiple '@'
return { local: email.slice(0, first), domain: email.slice(first + 1), quoted: false };
}
/** True when `s` is a dotted-quad with octets 0-255 and no leading zeros. */
function isIPv4(s: string): boolean {
const parts = s.split('.');
if (parts.length !== 4) return false;
return parts.every((p) => {
if (!/^\d+$/.test(p)) return false;
const n = Number(p);
return n >= 0 && n <= 255 && String(n) === p;
});
}
/** Push domain-level problems into the shared reasons/warnings arrays. */
function validateDomain(domain: string, reasons: string[], warnings: string[]): void {
if (!domain) {
reasons.push('Domain is empty');
return;
}
if (domain.length > DOMAIN_MAX) {
reasons.push(`Domain exceeds ${DOMAIN_MAX} characters`);
}
// IP-literal domain: [1.2.3.4] or [IPv6:...].
if (domain.startsWith('[') && domain.endsWith(']')) {
const inner = domain.slice(1, -1);
if (/^ipv6:/i.test(inner)) {
warnings.push('IPv6 literal domain (uncommon; ensure your provider supports it)');
return;
}
if (isIPv4(inner)) {
warnings.push('IP-literal domain (uncommon; ensure your provider supports it)');
return;
}
reasons.push('Invalid IP-literal domain');
return;
}
if (domain.startsWith('[') || domain.endsWith(']')) {
reasons.push('Malformed IP-literal domain (unmatched brackets)');
return;
}
if (!domain.includes('.')) {
reasons.push('Domain must contain at least one dot (e.g. example.com)');
return;
}
const labels = domain.split('.');
for (const label of labels) {
if (!label) {
reasons.push('Domain contains an empty label (consecutive or trailing dots)');
continue;
}
if (label.length > 63) reasons.push('Domain label exceeds 63 characters');
if (!/^[A-Za-z0-9-]+$/.test(label)) reasons.push('Domain label contains invalid characters');
if (label.startsWith('-') || label.endsWith('-')) reasons.push('Domain label starts or ends with a hyphen');
}
const tld = labels[labels.length - 1];
if (!/^[A-Za-z]{2,}$/.test(tld)) reasons.push('Top-level domain must be at least two letters');
}
/** Validate a single email address; returns a structured verdict, never throws. */
export function validateEmail(raw: string): EmailResult {
const reasons: string[] = [];
const warnings: string[] = [];
const email = raw.trim();
if (!email) {
return { valid: false, reasons: ['Email is empty'], warnings };
}
if (email.length > TOTAL_MAX) {
reasons.push(`Email exceeds maximum length of ${TOTAL_MAX} characters`);
}
const split = splitLocalDomain(email);
if (!split) {
reasons.push('Email must contain exactly one "@" separating local part and domain');
return { valid: false, reasons, warnings };
}
const { local, domain, quoted } = split;
if (quoted) {
if (local.length > LOCAL_MAX) reasons.push(`Local part exceeds ${LOCAL_MAX} characters`);
warnings.push('Quoted local part (rarely supported by providers)');
} else if (!local) {
reasons.push('Local part is empty');
} else {
if (local.length > LOCAL_MAX) reasons.push(`Local part exceeds ${LOCAL_MAX} characters`);
if (local.startsWith('.') || local.endsWith('.')) reasons.push('Local part starts or ends with a dot');
if (local.includes('..')) reasons.push('Local part contains consecutive dots');
if (!LOCAL_CHARS.test(local)) reasons.push('Local part contains invalid characters');
}
if (!quoted && local.includes('+')) {
warnings.push('Plus-addressing (tag) detected - delivers to the base mailbox');
}
validateDomain(domain, reasons, warnings);
const valid = reasons.length === 0;
return {
valid,
local,
domain,
normalized: local && domain ? `${local}@${domain.toLowerCase()}` : undefined,
reasons,
warnings,
};
}
/** Validate many emails (one per line); blank/whitespace-only lines are skipped. */
export function validateBatch(input: string): EmailResult[] {
if (!input) return [];
return input
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map(validateEmail);
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →