Type Your File Validation Library as a Security Boundary
Beyond Boolean Gates: Enforcing File Validation as a Compile-Time Security Boundary
Current Situation Analysis
File upload validation is routinely treated as a runtime gatekeeper rather than a type-level contract. Most codebases implement a validation routine that returns a flat object containing a boolean flag, an optional error message, and an optional payload. The pattern looks pragmatic on day one:
interface UploadCheck {
passed: boolean;
message?: string;
file?: File;
}
The problem emerges at the consumption layer. TypeScript's structural type system cannot infer that file is guaranteed to exist when passed is true. Developers are forced to use non-null assertions (file!) or defensive if (file) checks that the compiler never enforces. Over time, these assertions become muscle memory. A new engineer, a rushed hotfix, or a refactored component will inevitably skip the guard. The validation function executes, the boolean flips, but the type system remains completely unaware of the security boundary that was supposed to be crossed.
This pattern persists because it mimics synchronous, stateless validation. It ignores three realities of modern file handling:
- Validation is a state transition, not a yes/no question. Files move through quarantine, async malware scanning, polyglot detection, and format verification. A single boolean cannot represent these lifecycle stages.
- Error context is security telemetry. Flattening rejection reasons into a single string discards actionable data: actual vs. allowed byte counts, declared vs. detected MIME types, and scanner threat categories.
- Trust must be trackable. Once a file passes validation, downstream services (storage clients, image processors, PDF renderers) must be able to distinguish between a vetted payload and a raw browser
Fileobject. Without type-level distinction, the trust boundary exists only in developer memory.
Modern schema validation libraries (Zod, Valibot, ArkType, Effect Schema) solved this for JSON payloads years ago by adopting discriminated unions and branded outputs. File validation lags behind due to DOM API constraints and async scanning requirements, leaving a dangerous gap between runtime checks and compile-time guarantees.
WOW Moment: Key Findings
Shifting from boolean-flag validation to a type-enforced boundary transforms how consumers interact with upload pipelines. The following comparison illustrates the operational impact:
| Approach | Compile-Time Enforcement | Error Context Granularity | Trust Boundary Persistence |
|---|---|---|---|
| Boolean + Optional Payload | None (requires manual assertions) | Flat string (lossy) | None (raw File type leaks) |
| Discriminated Union + Branded Output | Strict (narrowing required) | Structured variants (audit-ready) | Persistent (phantom brand tracks provenance) |
This finding matters because it moves validation from a suggestion to a contract. When the type system enforces state handling, new rejection categories trigger compile errors instead of silent fallbacks. When trust is branded, downstream services cannot accidentally process unvetted uploads. The shift eliminates an entire class of security regressions without adding runtime overhead.
Core Solution
Building a type-safe file validation boundary requires three coordinated TypeScript patterns: discriminated unions for state modeling, branded types for trust provenance, and exhaustiveness checks for consumer enforcement. The implementation below demonstrates a production-ready approach.
Step 1: Model Validation States as a Discriminated Union
Replace the boolean flag with an explicit state machine. Each variant carries only the data relevant to its lifecycle stage.
type UploadOutcome =
| { phase: 'approved'; payload: TrustedFile }
| { phase: 'quarantined'; scanId: string; expiresAt: Date }
| { phase: 'rejected'; cause: RejectionDetail }
| { phase: 'expired'; receivedAt: Date; scanId: string };
The phase discriminator enables TypeScript to narrow the type automatically. Consumers cannot access payload without first proving phase === 'approved'. The quarantined and expired states handle async scanning workflows without forcing developers to invent ad-hoc state management.
Step 2: Structure Rejection Details as Enumerated Variants
Error messages should never be strings. They should be structured data that UI layers, audit logs, and security dashboards can consume directly.
type RejectionDetail =
| { type: 'size_violation'; limit: number; actual: number }
| { type: 'format_mismatch'; declared: string; detected: string }
| { type: 'extension_blocked'; provided: string; allowed: readonly string[] }
| { type: 'scanner_alert'; threatClass: string; scanId: string }
| { type: 'filename_policy'; violation: string };
Each variant carries exactly what downstream systems need. A size violation includes both the limit and the actual byte count, enabling precise UI feedback. A format mismatch preserves the declared MIME type alongside the magic-byte detection result, which is critical for investigating upload bypass attempts.
Step 3: Implement Branded Types for Trust Provenance
The browser File object is a global DOM type. You cannot modify it, and you cannot attach metadata to it safely. Branded types solve this by adding a phantom property that the type system tracks but the runtime ignores.
declare const TRUST_BRAND: unique symbol;
type TrustedFile = File & { readonly [TRUST_BRAND]: true };
The validator's job is to produce a TrustedFile, not a raw File. Downstream functions explicitly require the branded type, making it impossible to pass unvetted uploads into storage clients or processors.
Step 4: Enforce Exhaustive Consumption
TypeScript's never type forces developers to handle every possible state. This prevents silent fallbacks when new validation rules are added.
function handleUploadOutcome(outcome: UploadOutcome): void {
switch (outcome.phase) {
case 'approved':
persistToStorage(outcome.payload);
break;
case 'quarantined':
notifyUser('Scanning in progress...');
schedulePoll(outcome.scanId, outcome.expiresAt);
break;
case 'rejected':
logSecurityEvent(outcome.cause);
renderRejectionUI(outcome.cause);
break;
case 'expired':
notifyUser('Scan timed out. Please retry.');
break;
default:
const _exhaustive: never = outcome;
throw new Error(`Unhandled upload phase: ${_exhaustive}`);
}
}
If a new phase like 'flagged_for_review' is added later, the default branch will trigger a compile error. The type system becomes the enforcement mechanism for security policy updates.
Pitfall Guide
1. The Phantom Brand Leak
Explanation: A validated file is extracted from the union and passed to a function that accepts a raw File. The brand is lost, and the trust boundary collapses.
Fix: Define downstream consumers to explicitly require TrustedFile. Use TypeScript's satisfies operator or strict function signatures to prevent implicit downcasting.
2. Exhaustiveness Bypass via Default Cases
Explanation: Developers write a default case that silently ignores unknown states, defeating the purpose of discriminated unions.
Fix: Always assign the default value to a never typed variable. This forces a compile error if a new variant is introduced without a corresponding handler.
3. Mutable Validation Payloads
Explanation: Consumers modify the rejection details or scan metadata before logging or displaying them, corrupting audit trails.
Fix: Mark all result properties as readonly. Use ReadonlyArray for allowed extensions and threat categories. TypeScript will prevent accidental mutations at compile time.
4. Async State Neglect
Explanation: Validation functions return immediately without accounting for async malware scanners, forcing developers to invent temporary state flags.
Fix: Model quarantined and expired as first-class union members. Return scan identifiers and expiration timestamps so consumers can implement polling or webhook-based resolution without breaking the type contract.
5. String-Flattened Diagnostics
Explanation: Rejection reasons are concatenated into a single message for UI display, losing structured data needed for security telemetry. Fix: Keep rejection details as discriminated variants. Create a separate presentation layer that maps variants to user-facing strings. Never mix security data with display logic.
6. Cross-Context Type Casting
Explanation: Developers use as TrustedFile to bypass validation in legacy code paths, creating invisible security holes.
Fix: Disable noImplicitAny and strictNullChecks. Use ESLint rules to flag unsafe type assertions. Require code reviews for any as keyword usage in upload pipelines.
7. Ignoring Browser File API Quirks
Explanation: Relying on file.type for MIME detection, which is often empty or spoofed by browsers.
Fix: Always perform magic-byte detection or use a library like file-type for synchronous header inspection. Treat file.type as a hint, not a guarantee. Document this limitation in the validator's contract.
Production Bundle
Action Checklist
- Replace boolean validation results with discriminated unions containing explicit lifecycle states
- Implement branded types to distinguish validated files from raw DOM
Fileobjects - Structure all rejection reasons as enumerated variants with contextual metadata
- Enforce exhaustiveness using
nevertype assignments in consumer switch statements - Mark all validation result properties as
readonlyto prevent audit corruption - Model async scanning states (
quarantined,expired) as first-class union members - Disable unsafe type assertions in ESLint and enforce strict compiler flags
- Create a presentation mapper layer to convert structured rejections into UI strings
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Simple internal form upload | Discriminated union + basic rejection variants | Low complexity, fast iteration, sufficient for non-security-critical paths | Minimal engineering overhead |
| Enterprise file gateway with malware scanning | Full branded boundary + async states + exhaustiveness | Prevents unvetted files from reaching storage, enforces security policy updates | Moderate upfront cost, high risk reduction |
| Legacy codebase migration | Gradual adoption with adapter functions | Allows incremental refactoring without breaking existing consumers | Low immediate cost, requires discipline to prevent brand leaks |
| High-throughput S3 ingestion pipeline | Branded types + strict consumer signatures | Guarantees only validated payloads reach storage clients, eliminates runtime type checks | High initial investment, near-zero regression risk |
Configuration Template
// types.ts
declare const TRUST_BRAND: unique symbol;
export type TrustedFile = File & { readonly [TRUST_BRAND]: true };
export type RejectionDetail =
| { type: 'size_violation'; limit: number; actual: number }
| { type: 'format_mismatch'; declared: string; detected: string }
| { type: 'extension_blocked'; provided: string; allowed: readonly string[] }
| { type: 'scanner_alert'; threatClass: string; scanId: string };
export type UploadOutcome =
| { phase: 'approved'; payload: TrustedFile }
| { phase: 'quarantined'; scanId: string; expiresAt: Date }
| { phase: 'rejected'; cause: RejectionDetail }
| { phase: 'expired'; receivedAt: Date; scanId: string };
// validator.ts
export function validateUpload(raw: File): UploadOutcome {
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED = ['pdf', 'png', 'jpg'] as const;
if (raw.size > MAX_BYTES) {
return { phase: 'rejected', cause: { type: 'size_violation', limit: MAX_BYTES, actual: raw.size } };
}
const ext = raw.name.split('.').pop()?.toLowerCase() ?? '';
if (!ALLOWED.includes(ext as typeof ALLOWED[number])) {
return { phase: 'rejected', cause: { type: 'extension_blocked', provided: ext, allowed: ALLOWED } };
}
// Simulate async scanner handoff
const scanId = crypto.randomUUID();
return { phase: 'quarantined', scanId, expiresAt: new Date(Date.now() + 300_000) };
}
// consumer.ts
export function processUpload(outcome: UploadOutcome): void {
switch (outcome.phase) {
case 'approved':
uploadToBucket(outcome.payload);
break;
case 'quarantined':
queueForScan(outcome.scanId, outcome.expiresAt);
break;
case 'rejected':
auditLog.write(outcome.cause);
break;
case 'expired':
triggerRetry(outcome.scanId);
break;
default:
const _check: never = outcome;
throw new Error(`Unhandled phase: ${_check}`);
}
}
Quick Start Guide
- Define the brand: Add a unique symbol and create a
TrustedFiletype that intersectsFilewith the brand. - Model states: Replace your existing validation return type with a discriminated union containing
approved,rejected,quarantined, andexpiredvariants. - Structure errors: Convert string-based rejection messages into enumerated variants that carry contextual metadata (limits, actual values, scan IDs).
- Enforce consumption: Update all upload handlers to use
switchstatements with anever-typed default branch. Compile errors will immediately surface missing handlers. - Lock downstream signatures: Change storage and processing functions to accept
TrustedFileinstead ofFile. The compiler will reject any attempt to pass unvetted uploads.
