Blocking throwaway email addresses is a solved problem in theory and a recurring annoyance in practice. disposable-email-validator is a zero-dependency TypeScript library that handles it with a design choice I don't see often: the validation behavior is keyed to the environment.
What it does
Single class, single method. You configure it with an environment name and a rule object, then call validateEmail(). It returns { success: true, error: null } or { success: false, error: string }.
Why I starred it
The usual pattern is a boolean flag or a static list. What caught me here is the environment-aware config model: the same code does strict validation in production and lets everything through in development. That's not exotic, but most email validation libraries don't bake it in — they leave that branching logic to you.
The other thing: it ships with a prebuilt blocklist of ~4,400 disposable domains sourced from the disposable-email-domains project. You get that coverage without wiring up an external service.
How it works
The constructor in src/validator.ts resolves the domain list at initialization time, not at call time. When you instantiate with an environment:
constructor(environment: KnownEnvironment | string, config: Config) {
const envConfig = config[environment];
if (!envConfig) throw new Error(`Invalid environment: ${environment}`);
this.rules = envConfig.rules;
let domainsToUse: string[];
if (envConfig.disposableDomains) {
const shouldMerge = envConfig.mergeDisposableDomains ?? true;
domainsToUse = shouldMerge
? [...Array.from(DEFAULT_BLOCKED_DOMAINS), ...envConfig.disposableDomains]
: envConfig.disposableDomains;
} else {
domainsToUse = Array.from(DEFAULT_BLOCKED_DOMAINS);
}
this.disposableDomains = new Set(domainsToUse.map((d) => d.toLowerCase()));
this.trustedDomains = envConfig.trustedDomains
? new Set(envConfig.trustedDomains.map((a) => a.toLowerCase()))
: null;
}
The domain list gets flattened into a Set<string> once. Every validateEmail() call is then a Set.has() lookup — O(1) per check. The mergeDisposableDomains flag (defaults to true) controls whether your custom domains extend or replace the built-in list. Setting it to false lets you use only your own blocklist, which is useful for internal tools where you want to block specific corporate throwaway patterns without carrying 4,400 entries.
The validation order in validateEmail() is explicit and documented in the JSDoc:
- Format check (presence of
@, non-empty local part and domain) - Trusted domains allowlist — if the email or its domain is in the set, return early with success
- Disposable domain check
- Plus addressing check
The format check is minimal — lastIndexOf('@') to split the string, then verify both sides are non-empty. It doesn't run a regex against the full RFC 5321 spec. That means user@domain@com passes (the local part becomes user@domain, the domain becomes com), though the test suite explicitly documents this behavior as intentional.
The DEFAULT_BLOCKED_DOMAINS lives in src/data/disposable-domains.ts as a Set<string> literal. It's ~4,400 entries exported directly — no lazy loading, no external fetch. The whole list loads when you import the module. Fine for a server process; something to think about if you're shipping this to a browser bundle.
Using it
import { DisposableEmailValidator } from 'disposable-email-validator';
const validator = new DisposableEmailValidator(process.env.NODE_ENV, {
development: {
rules: { allow_disposable_emails: true, allow_plus_addressing: true }
},
production: {
rules: { allow_disposable_emails: false, allow_plus_addressing: false },
trustedDomains: ['company.com'],
disposableDomains: ['internal-temp.io'],
mergeDisposableDomains: true
}
});
const result = validator.validateEmail(req.body.email);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
The trustedDomains array accepts both full email addresses and bare domains. Entries there bypass all other checks — useful for seeding test accounts or whitelisting internal addresses that would otherwise trigger the disposable domain check.
Rough edges
The format validation is shallow. There's no check for a valid TLD, no length enforcement on local parts or domains, and no normalization beyond lowercasing and trimming. user@com passes. For most signup flows that's fine — you're catching throwaway domains, not validating RFC compliance — but if you expect robust format rejection, you'll want to pair this with something like zod's .email() upstream.
The blocklist is baked in at import time. If you need a frequently updated list (some disposable providers rotate domains), you're pinned to library releases. The disposable-email-domains project it sources from does receive updates, so the question is how often the library cuts a new version.
No framework adapters. Connecting it to Zod, Yup, or Valibot is a one-liner, but the library doesn't ship those integrations. The README doesn't cover it.
Tests are solid — test/validator.test.ts covers format edge cases, merge behavior, validation priority order, and environment switching. The mergeDisposableDomains: false case with an empty custom array is explicitly tested, which is the kind of edge case that tends to break in production.
Bottom line
Useful if you're building a signup flow and want environment-aware disposable email blocking without reaching for an API. The design is clean, the tests are thorough, and the zero-dependency footprint makes it easy to drop in. Run it after your schema validation, not instead of it.
