Skip to content

Repository files navigation

filidate

npm version CI license types

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

Contents

Why

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 human message, plus path/expected/received so 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.

Quick start

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.

Entry points

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.

Accepted inputs

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.

Rules

Every rule is optional. A rule that needs metadata the file cannot provide is skipped, not failed.

Size

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_REQUIRED

Any 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." } });

MIME type

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.

Extension and signature

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.

Filename

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
  },
});

Image

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.

PDF

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.

Audio

file({
  audio: {
    minDuration: 1, maxDuration: 300,      // seconds
    minBitrate: 64000, maxBitrate: 320000,
    minSampleRate: 44100, maxSampleRate: 48000,
    minChannels: 1, maxChannels: 2,
    allowedCodecs: ["mp3"],
  },
});

Video

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,
  },
});

Archive

file({
  archive: {
    allowedFormats: ["zip"],
    maxEntries: 100,
    rejectExecutables: true,       // an entry that is an executable
    rejectNestedArchives: true,    // an archive inside the archive
  },
});

Security

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

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.

Validating multiple files

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 metadata

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

Custom rules

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.

Inspecting without validating

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.

Shared configuration and i18n

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.

Plugins

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 pdf object replaces the core-detected one entirely. Spread the existing values if you only mean to add fields.
  • A plugin whose supports or inspect throws 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.

Error reference

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

Recipes

React file input

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.

Next.js route handler

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 });
}

Express with multer

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 });
});

Node script over files on disk

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.

API reference

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.

Gotchas

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.

Development

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 bench

Requires 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 changeset

Merging to main opens a release PR; merging that PR publishes to npm with provenance via GitHub Actions.

License

MIT © Lelianto Pradana


🚀 More TypeScript Projects

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.

Explore the ecosystem

⭐ If you enjoy this project, consider giving it a star. It helps others discover the ecosystem.

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages