Lightweight, extensible, type-safe file validation for browsers and Node.js.
Declare what a valid file looks like, then validate File, Blob, Buffer, ArrayBuffer, Uint8Array, or a file path against it. filidate reads only the bytes the active rules actually need — it never loads a whole file into memory to check its dimensions.
npm install @antihero/filidate
# pnpm add @antihero/filidate
# yarn add @antihero/filidate- Why
- Quick start
- Entry points
- Accepted inputs
- Rules
- Presets
- Validating multiple files
- Custom rules
- Inspecting without validating
- Shared configuration and i18n
- Plugins
- Error reference
- Recipes
- API reference
- Gotchas
- Development
Trusting file.type and the filename extension is not validation — both are attacker-controlled. A .png upload can be a Windows executable, and Content-Type: image/png is just a string the client chose. filidate reads the file's actual leading bytes, identifies the real format, and reports every failure as a structured, machine-readable error.
- Content-aware — detects the true format from file signatures (magic numbers), then cross-checks it against the declared MIME type and the extension.
- Zero runtime dependencies, dual ESM/CJS, ~23 KB gzipped, with a separate browser build that references no Node built-ins.
- Lazy, ranged byte reads — image dimensions, PDF page counts, and media duration come from header ranges, not full decodes.
- Structured errors — a stable
code, a humanmessage, pluspath/expected/receivedso you can build your own UI or i18n layer. - Skip-not-fail semantics — a rule whose metadata is unavailable is skipped, so
image: { minWidth: 1000 }never rejects a PDF. - Extensible — custom rules, plugins for deep inspection, and fully overridable messages.
import { file } from "@antihero/filidate";
const avatar = file({
maxSize: "2MB",
mimeTypes: ["image/png", "image/jpeg"],
signatures: ["png", "jpeg"],
image: { minWidth: 200, minHeight: 200, aspectRatio: "1:1" },
});
const result = await avatar.validate(input);
if (!result.success) {
for (const error of result.errors) {
console.log(error.code, error.message);
// INVALID_ASPECT_RATIO Image aspect ratio must be 1:1 (received 1.5).
}
} else {
console.log(result.metadata.image?.width);
}validate() never throws for a validation failure — failures come back in result.errors. If you prefer exceptions, use parse(), which throws a FileValidationError carrying the same array:
import { FileValidationError } from "@antihero/filidate";
try {
const normalized = await avatar.parse(input);
} catch (error) {
if (error instanceof FileValidationError) console.log(error.errors);
}Every schema method is async, because reading bytes is async in both environments.
The default import auto-selects the right build through the browser export condition, so most projects just use @antihero/filidate. Import a specific entry when a bundler guesses wrong:
import { file } from "@antihero/filidate"; // auto: Node or browser
import { file } from "@antihero/filidate/node"; // Node build — string inputs read from disk
import { file } from "@antihero/filidate/browser"; // browser build — no Node built-ins
import { imageFile } from "@antihero/filidate/presets";@antihero/filidate/node |
@antihero/filidate/browser |
|
|---|---|---|
File, Blob, Uint8Array, ArrayBuffer |
Supported | Supported |
Buffer |
Supported | Not available in browsers |
Path string |
Read from disk | Rejected as INVALID_INPUT |
References node:fs / node:path |
Yes | No — asserted in CI |
Only the Node build registers the file-path reader. Passing a string to the browser build is a deliberate error rather than a silent pass, so a server-side path never leaks into client code unnoticed.
File, Blob, Buffer, Uint8Array, ArrayBuffer, SharedArrayBuffer, and — in the Node build — a path string.
All inputs are normalized to a NormalizedFile:
interface NormalizedFile {
name: string; // "" for raw byte inputs — see Gotchas
extension?: string; // lowercased, no leading dot
declaredMimeType?: string; // from File.type; absent for raw bytes
detectedMimeType?: string; // derived from content
size: number;
source: FileInput;
environment: "browser" | "node";
readBytes(start?: number, end?: number): Promise<Uint8Array>;
}A path that does not exist fails with INVALID_INPUT rather than throwing.
On Node.js 18 there is no global File — import it from node:buffer if you need to construct one. filidate detects both that class and the global File from Node 20+, so filenames survive either way.
Every rule is optional. A rule that needs metadata the file cannot provide is skipped, not failed.
ByteSize accepts a raw byte count or a string with a B/KB/MB/GB/TB or KiB/MiB/GiB/TiB suffix.
file({ minSize: "1KB", maxSize: "5MB" });
file({ size: 1024 }); // exact
file({ required: true }); // reject null/undefined input with FILE_REQUIREDAny rule value can be wrapped as { value, message } to override just that rule's message:
file({ maxSize: { value: "2MB", message: "Keep it under 2 MB." } });file({
mimeTypes: ["image/*", "application/pdf"], // wildcards allowed
mimeCheck: "declared-and-detected",
});mimeCheck |
Behavior |
|---|---|
declared |
Only the input's declared type is checked. |
detected |
Only the type detected from content is checked. |
declared-or-detected |
Either passes. Default. |
declared-and-detected |
Both must pass — strictest. |
For untrusted uploads prefer detected or declared-and-detected; the default is permissive because raw byte inputs have no declared type at all.
file({
extensions: [".png", "jpg"], // dots optional, case-insensitive
requireExtensionMatch: true, // extension must agree with detected content
signatures: ["png", "jpeg"], // magic-number allowlist
signatureHeaderBytes: 512, // bytes read for detection (default 512)
});requireExtensionMatch is what catches payload.png that is actually a ZIP — it fails with EXTENSION_CONTENT_MISMATCH.
Detected formats: png jpeg gif webp avif heic bmp tiff svg pdf zip rar 7z gzip tar mp3 wav ogg mp4 mov webm mkv exe elf macho.
file({
filename: {
minLength: 1,
maxLength: 120,
pattern: /^[\w.-]+$/, // must fully match
forbiddenCharacters: ["/", "\\"],
rejectReservedNames: true, // CON, NUL, COM1, ...
rejectDoubleExtensions: true, // photo.jpg.exe
rejectPathTraversal: true, // ../ traversal attempts
rejectHiddenFiles: false, // default false
rejectNullBytes: true, // default true
normalizeUnicode: true, // NFKC before checks, default true
},
});file({
image: {
minWidth: 200,
minHeight: 200,
maxWidth: 4096,
maxHeight: 4096,
exactWidth: 800,
exactHeight: 600,
aspectRatio: "16:9", // or a number like 1.7778
aspectRatioTolerance: 0.01, // default
orientation: "landscape", // portrait | landscape | square
allowAnimation: false, // default false — rejects animated GIF/WebP
allowTransparency: false, // default false — rejects an alpha channel
},
});Dimensions come from the image header, so a 50 MP JPEG costs a few kilobytes to check. An invalid ratio literal such as "not-a-ratio" throws a TypeError when the schema is built, not when a file arrives — so typos surface at startup.
file({ pdf: { minPages: 1, maxPages: 50, encrypted: false, maxVersion: "1.7" } });Page counts are header heuristics, not a full parse. For exact counts on unusual PDFs, add a plugin.
file({
audio: {
minDuration: 1, maxDuration: 300, // seconds
minBitrate: 64000, maxBitrate: 320000,
minSampleRate: 44100, maxSampleRate: 48000,
minChannels: 1, maxChannels: 2,
allowedCodecs: ["mp3"],
},
});file({
video: {
minDuration: 1, maxDuration: 600,
minWidth: 1280, minHeight: 720,
maxWidth: 3840, maxHeight: 2160,
aspectRatio: "16:9",
aspectRatioTolerance: 0.01,
allowedCodecs: ["h264"],
minBitrate: 100000, maxBitrate: 20000000,
},
});file({
archive: {
allowedFormats: ["zip"],
maxEntries: 100,
rejectExecutables: true, // an entry that is an executable
rejectNestedArchives: true, // an archive inside the archive
},
});Opt-in hardening for untrusted uploads. All default to false.
file({
security: {
requireSignatureMatch: true, // content must be a recognized format
rejectExecutableFiles: true, // exe, elf, mach-o, shebang scripts
rejectPolyglotFiles: true, // matches more than one format at once
rejectDoubleExtensions: true,
rejectNullBytes: true,
rejectPathTraversal: true,
rejectSuspiciousMimeMismatch: true, // declared type contradicts content
},
});A reasonable baseline for public uploads:
const publicUpload = file({
required: true,
maxSize: "10MB",
mimeTypes: ["image/png", "image/jpeg", "application/pdf"],
mimeCheck: "declared-and-detected",
requireExtensionMatch: true,
filename: { maxLength: 200, rejectPathTraversal: true, rejectDoubleExtensions: true },
security: {
requireSignatureMatch: true,
rejectExecutableFiles: true,
rejectPolyglotFiles: true,
rejectSuspiciousMimeMismatch: true,
},
});Presets are ordinary file() calls and stay transparent — the resolved options are always readable on schema.options and schema.effectiveOptions. Anything you pass overrides the preset default.
import { imageFile, avatarFile, documentFile, pdfFile, audioFile, videoFile } from "@antihero/filidate/presets";| Preset | Defaults |
|---|---|
imageFile(opts?) |
image/* |
avatarFile(opts?) |
image/*, min 400×400, aspect ratio 1:1 |
documentFile(opts?) |
pdf, doc, docx, txt, rtf — max 10 MB |
pdfFile(opts?) |
application/pdf + .pdf + pdf signature — max 10 MB |
audioFile(opts?) |
audio/* — max 20 MB |
videoFile(opts?) |
video/* — max 100 MB |
avatarFile({ maxSize: "1MB" }); // override just the size
avatarFile({ minWidth: 200, minHeight: 200 }); // relax the dimensions
avatarFile().effectiveOptions.image; // { minWidth: 400, minHeight: 400, aspectRatio: "1:1" }imageFile and avatarFile accept image constraints at the top level as a shorthand; the other presets take the full FileSchemaOptions.
files() accepts an array, a FileList, or any iterable.
import { files, imageFile } from "@antihero/filidate";
const gallery = files({
minFiles: 1,
maxFiles: 10,
maxTotalSize: "25MB",
each: imageFile({ maxSize: "5MB" }), // a schema, or plain options
});
const result = await gallery.validate(event.target.files);
result.success; // false if the collection or any file failed
result.errors; // flat list; every per-file error carries metadata.index
result.files[2].errors; // per-file, in input order
result.files[2].metadata; // per-file metadataCollection-level codes are FILE_COUNT_MIN, FILE_COUNT_MAX, and TOTAL_SIZE_EXCEEDED. To show errors next to each input, read result.files[i]; to show one summary list, use result.errors and group by metadata.index.
Return null (or nothing) to pass. Return a string to fail with that message under CUSTOM_VALIDATION_FAILED, or a ValidationError — or an array of them — for full control over the code.
file({
custom: [
// 1. bare string = failure message
(ctx) => (ctx.file.name.startsWith("tmp-") ? "Temporary files are not accepted." : null),
// 2. async, with your own code
async (ctx) => {
const head = await ctx.readBytes(0, 16); // cached within this cycle
if (head[0] === 0x00) {
return { code: "LEADING_NULL", message: "File starts with a null byte." };
}
return null;
},
// 3. object form with a name, and multiple errors at once
{
name: "checks",
validate: () => [
{ code: "E1", message: "first problem" },
{ code: "E2", message: "second problem" },
],
},
],
});ctx is a RuleContext with file (the NormalizedFile), metadata, and readBytes(start, end). Reads are cached per validation cycle, so overlapping ranges are fetched once.
Name a rule for reuse with defineFileRule:
import { defineFileRule } from "@antihero/filidate";
const noTempFiles = defineFileRule({
name: "noTempFiles",
validate: (ctx) => (ctx.file.name.startsWith("tmp-") ? "Temporary files are not accepted." : null),
});
file({ custom: [noTempFiles] });A rule that throws is caught and reported as CUSTOM_VALIDATION_FAILED — one bad rule cannot crash the request.
import { inspectFile } from "@antihero/filidate";
const metadata = await inspectFile(input);
// {
// name, extension, size, declaredMimeType, detectedMimeType, signature,
// image?: { width, height, aspectRatio, orientation, animated, transparent, format },
// pdf?: { pages, version, encrypted },
// audio?: { duration, bitrate, sampleRate, channels, codec, format },
// video?: { duration, width, height, aspectRatio, codec, bitrate, format },
// archive?: { format, entries, entryNames },
// }Useful for showing a preview, storing dimensions, or routing by real type. schema.inspect(input) does the same through an existing schema. Unlike validate, inspectFile throws if the input cannot be normalized at all.
createFileValidator binds messages, locale, and plugins once and returns file, files, and inspectFile pre-configured.
import { createFileValidator } from "@antihero/filidate";
const v = createFileValidator({
locale: {
FILE_TOO_LARGE: ({ expected, received }) => `Berkas maksimal ${expected} bita (dikirim ${received}).`,
INVALID_MIME_TYPE: "Jenis berkas tidak didukung.",
},
maxReadBytes: 512 * 1024, // cap per read, default 1 MiB
});
const schema = v.file({ maxSize: "2MB" });A locale entry is either a plain string or a resolver receiving { code, message, expected, received }. Any error code can be overridden; unlisted codes fall back to English. Precedence is: a rule's inline message → your locale entry → the built-in default.
Core inspection stays header-only and dependency-free. Plugins add deeper analysis — full PDF parsing, media probing, EXIF, antivirus — without pulling weight into the core bundle.
import type { FileValidatorPlugin } from "@antihero/filidate";
const pdfPagesPlugin: FileValidatorPlugin = {
name: "pdf-pages",
supports: (file) => file.detectedMimeType === "application/pdf",
async inspect(file, ctx) {
const bytes = await ctx.readBytes(0, 64 * 1024);
return { pdf: { pages: countPages(bytes), version: "1.7" } };
},
};
file({ plugins: [pdfPagesPlugin] });Two behaviors to know:
- Returned keys are shallow-merged, so a returned
pdfobject replaces the core-detected one entirely. Spread the existing values if you only mean to add fields. - A plugin whose
supportsorinspectthrows is skipped silently — its metadata is simply absent and validation continues. Rules that depended on that metadata are then skipped rather than failed, so a broken plugin loosens validation instead of breaking it. Log inside your plugin if you need visibility.
Every failure is a ValidationError:
{
code: "FILE_TOO_LARGE", // stable, safe to switch on
message: "File is too large. Expected at most 2 MB, received 5 MB.",
path: "size", // e.g. "size", "image.width"
expected: 2097152,
received: 5242880,
metadata: { index: 0 }, // present for collection errors
}| Group | Codes |
|---|---|
| Input | FILE_REQUIRED, INVALID_INPUT |
| Size | FILE_TOO_SMALL, FILE_TOO_LARGE, FILE_SIZE_MISMATCH |
| Type | INVALID_MIME_TYPE, INVALID_EXTENSION, SIGNATURE_MISMATCH, EXTENSION_CONTENT_MISMATCH |
| Filename | INVALID_FILENAME, FILENAME_TOO_SHORT, FILENAME_TOO_LONG, FILENAME_FORBIDDEN_CHARACTER, FILENAME_RESERVED, FILENAME_DOUBLE_EXTENSION, FILENAME_PATH_TRAVERSAL, FILENAME_HIDDEN_FILE, FILENAME_NULL_BYTE |
| Image | INVALID_IMAGE_DIMENSIONS, INVALID_ASPECT_RATIO, IMAGE_ANIMATION_NOT_ALLOWED, IMAGE_TRANSPARENCY_NOT_ALLOWED |
PDF_PAGE_LIMIT_EXCEEDED, PDF_PAGE_MIN_NOT_MET, PDF_ENCRYPTED_NOT_ALLOWED, PDF_VERSION_EXCEEDED |
|
| Media | MEDIA_DURATION_EXCEEDED, MEDIA_DURATION_MIN_NOT_MET, MEDIA_BITRATE_EXCEEDED, MEDIA_BITRATE_MIN_NOT_MET, MEDIA_SAMPLE_RATE_EXCEEDED, MEDIA_SAMPLE_RATE_MIN_NOT_MET, MEDIA_CHANNELS_EXCEEDED, MEDIA_CHANNELS_MIN_NOT_MET, MEDIA_CODEC_NOT_ALLOWED |
| Video | VIDEO_DIMENSIONS_INVALID, INVALID_VIDEO_ASPECT_RATIO |
| Archive | ARCHIVE_FORMAT_NOT_ALLOWED, ARCHIVE_ENTRY_LIMIT_EXCEEDED, ARCHIVE_EXECUTABLE_REJECTED, ARCHIVE_NESTED_REJECTED |
| Security | SECURITY_SIGNATURE_MISMATCH, SECURITY_EXECUTABLE_REJECTED, SECURITY_POLYGLOT_REJECTED, SECURITY_NULL_BYTE_REJECTED, SECURITY_SUSPICIOUS_MIME_MISMATCH, SECURITY_DOUBLE_EXTENSION_REJECTED, SECURITY_PATH_TRAVERSAL_REJECTED |
| Collection | FILE_COUNT_MIN, FILE_COUNT_MAX, TOTAL_SIZE_EXCEEDED |
| Custom | CUSTOM_VALIDATION_FAILED |
ErrorCode is an open union (… | (string & {})), so custom rules may introduce their own codes while keeping autocomplete for the built-ins.
import { useState } from "react";
import { avatarFile } from "@antihero/filidate/presets";
const schema = avatarFile({ maxSize: "2MB" });
export function AvatarUpload() {
const [errors, setErrors] = useState<string[]>([]);
async function onChange(event: React.ChangeEvent<HTMLInputElement>) {
const selected = event.target.files?.[0];
if (!selected) return;
const result = await schema.validate(selected);
setErrors(result.success ? [] : result.errors.map((e) => e.message));
if (result.success) upload(selected);
}
return (
<>
<input type="file" accept="image/*" onChange={onChange} />
{errors.map((message) => <p key={message}>{message}</p>)}
</>
);
}Client-side validation is for fast feedback only. Always re-validate on the server — the same schema runs in both places.
import { file } from "@antihero/filidate";
const schema = file({
required: true,
maxSize: "5MB",
mimeTypes: ["image/png", "image/jpeg"],
mimeCheck: "declared-and-detected",
security: { requireSignatureMatch: true, rejectPolyglotFiles: true },
});
export async function POST(request: Request) {
const form = await request.formData();
const result = await schema.validate(form.get("file"));
if (!result.success) {
return Response.json(
{ errors: result.errors.map(({ code, message, path }) => ({ code, message, path })) },
{ status: 422 },
);
}
const bytes = await result.file!.readBytes();
return Response.json({ ok: true, width: result.metadata.image?.width });
}multer's memory storage hands you a Buffer, which carries no filename. Wrap it in a File so filename and extension rules actually run — see Gotchas.
import express from "express";
import multer from "multer";
import { file } from "@antihero/filidate";
const upload = multer({ storage: multer.memoryStorage() });
const schema = file({
maxSize: "5MB",
extensions: [".png", ".jpg", ".jpeg"],
requireExtensionMatch: true,
security: { requireSignatureMatch: true, rejectExecutableFiles: true },
});
app.post("/upload", upload.single("file"), async (req, res) => {
// Wrap the buffer so the name is preserved.
const candidate = new File([req.file.buffer], req.file.originalname, { type: req.file.mimetype });
const result = await schema.validate(candidate);
if (!result.success) return res.status(422).json({ errors: result.errors });
res.json({ ok: true });
});import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { pdfFile } from "@antihero/filidate/presets";
const schema = pdfFile({ pdf: { maxPages: 100 } });
for (const name of await readdir("./inbox")) {
const result = await schema.validate(join("./inbox", name)); // path string
console.log(name, result.success ? "ok" : result.errors[0].code);
}Only the bytes needed by the active rules are read, so scanning a directory of large PDFs stays cheap.
| Export | Description |
|---|---|
file(options?, config?) |
Build a single-file schema. |
files(options?, config?) |
Build a collection schema. |
inspectFile(input, config?) |
Extract metadata; no rules applied. |
defineFileRule(rule) |
Name a reusable custom rule. |
createFileValidator(config) |
Bind shared messages, locale, and plugins. |
FileSchema / FilesSchema |
Schema classes. |
FileValidationError |
Thrown by parse(); carries .errors. |
registerFilePathReader(reader) |
Supply a custom path reader. |
| Presets | imageFile, avatarFile, documentFile, pdfFile, audioFile, videoFile |
Schema methods, all async:
| Method | Returns |
|---|---|
validate(input) |
ValidationResult — never throws on a validation failure. |
parse(input) |
The NormalizedFile; throws FileValidationError. |
safeParse(input) |
Alias for validate. |
inspect(input) |
Metadata only. |
options / effectiveOptions |
The declared and resolved options. |
Exported types include FileInput, NormalizedFile, Environment, FileMetadata (and the Image/Pdf/Audio/Video/Archive variants), ValidationResult, ValidationError, ErrorCode, FileSchemaOptions, FilesSchemaOptions, ByteSize, Ratio, MimeCheckMode, CustomRule, CustomRuleResult, RuleContext, Locale, ValidatorConfig, and FileValidatorPlugin.
Raw byte inputs have no filename. A Buffer, Uint8Array, or ArrayBuffer normalizes to name: "" with no extension, so filename and extensions rules are skipped and pass. This is the most common way to think you are validating extensions when you are not. If the name matters, wrap the bytes in a File:
new File([buffer], originalName, { type: declaredMimeType });Blob has the same limitation — it carries a type but no name.
Raw byte inputs also have no declared MIME type, so under the default mimeCheck: "declared-or-detected" only the detected type is consulted. That is usually what you want, but be explicit with mimeCheck: "detected" if you rely on it.
The file-path reader is registered process-globally. Importing @antihero/filidate (the Node entry) anywhere in a process also enables path reads through @antihero/filidate/browser in that same process. This never happens in a real browser bundle, but it means a Node test that imports both entries will not observe the browser entry's path rejection.
Metadata absence loosens validation. Because unavailable metadata skips a rule, a corrupt or unrecognized file can pass narrow schemas. Pair format rules with security: { requireSignatureMatch: true } when you need a positive assertion about the content.
Page counts and media metadata are header heuristics. They are fast and allocation-light, not a full parse. Use a plugin when you need exactness.
pnpm install
pnpm test # vitest
pnpm test:coverage
pnpm lint
pnpm typecheck
pnpm build # tsup — ESM + CJS + browser builds
pnpm check:exports # attw + publint
pnpm check:size # gzipped budget per artifact
pnpm benchRequires Node.js 18 or later; CI verifies against 18, 20, and 22, and asserts the browser bundle contains no Node built-ins.
Releases run through changesets. Add one with your change:
pnpm changesetMerging to main opens a release PR; merging that PR publishes to npm with provenance via GitHub Actions.
MIT © Lelianto Pradana
If you find this package useful, you may also like these open-source projects.
| Project | Description |
|---|---|
| 💰 Monify | Lightweight currency formatting library with multi-currency support. |
| 🤖 AgentifAI | Vendor-neutral AI agent event model and debugging toolkit. |
| ⚡ Statelite | Lightweight reactive state management for TypeScript. |
| 🗄️ Nano Cache | Universal cache abstraction for memory, Redis, IndexedDB, and more. |
| 🔌 PlugnPlay | Bootstrap cloud backends with minimal configuration. |
| 🎨 Sagara UI | Utility-first CSS framework optimized for AI-assisted development. |
- 💰 Monify → https://github.com/Lelianto/monify
- 🤖 AgentifAI → https://github.com/Lelianto/agentifai
- ⚡ Statelite → https://github.com/Lelianto/statelite
- 🗄️ Nano Cache → https://github.com/Lelianto/nano-cache
- 🔌 PlugnPlay → https://github.com/Lelianto/plugnplay
- 🎨 Sagara UI → https://github.com/Lelianto/sagaraui
⭐ If you enjoy this project, consider giving it a star. It helps others discover the ecosystem.