Type Your File Validation Library as a Security Boundary
Compile-Time Trust Boundaries for File Upload Validation
Current Situation Analysis
File upload validation is routinely treated as a simple runtime gate. Teams write functions that accept raw input, run a series of checks, and return a verdict. The most common signature looks like a boolean flag paired with an optional payload and an optional error string. This pattern persists because it works functionally: the code runs, the checks execute, and the application proceeds.
The problem is architectural, not functional. TypeScript's structural type system cannot enforce a relationship between a boolean flag and the presence of a payload. When a validation function returns { valid: boolean; payload?: T; error?: string }, the compiler treats payload and error as independent optional fields. Consumers must either use non-null assertions (payload!) or write defensive checks that the type system cannot verify. Over time, these assertions become invisible noise. Developers skip them. Different modules handle the same validation result inconsistently. Some log the error string, some ignore it, some pass the raw payload downstream without verification.
This gap between runtime execution and compile-time enforcement is why file validation becomes a security liability. The type signature implies that validation is an opinion rather than a boundary. It allows unverified data to flow into trusted contexts, flattens rich rejection metadata into unparseable strings, and provides no mechanism to force consumers to handle every possible outcome. When threat models evolve (polyglot files, async malware scanning, quarantine windows), the boolean signature silently degrades. New rejection categories appear as arbitrary strings, existing consumers never break at compile time, and security telemetry loses context.
The industry has largely solved this for JSON schema validation using discriminated unions and branded types. File validation lags behind because it involves async states, binary inspection, and stricter security requirements. Treating validation as a type-level boundary rather than a runtime check closes the gap between what the code does and what the compiler guarantees.
WOW Moment: Key Findings
The shift from boolean-gated validation to type-enforced boundaries produces measurable improvements in safety, maintainability, and observability. The table below contrasts the traditional approach with a type-level boundary implementation across four production-critical dimensions.
| Approach | Compile-Time Enforcement | Error Granularity | Consumer Exhaustiveness | Security Posture |
|---|---|---|---|---|
| Boolean Gate + Optional Payload | None. Relies on manual ! assertions or runtime checks. | Flattened to strings. Loses thresholds, observed values, and threat categories. | Optional. Consumers can ignore branches or skip error handling entirely. | Fragile. Unverified data shares the same type as verified data. |
| Discriminated Union + Branded Types | Strict. Payload access requires type narrowing. Compiler blocks unguarded usage. | Structured objects. Each rejection carries exact metadata for UI, telemetry, and audit. | Mandatory. Exhaustiveness checks force handling of every state. | Strong. Verified data carries a nominal brand that legacy functions cannot accept. |
This finding matters because it transforms validation from a runtime habit into a compiler contract. When the type system enforces the boundary, security reviews shift from auditing developer discipline to verifying type definitions. New threat categories trigger compile errors instead of silent fallbacks. Telemetry pipelines receive structured rejection data instead of parsing regex against error strings. The cost of adding a new validation rule becomes a type update, not a coordination exercise across multiple modules.
Core Solution
Building a type-level validation boundary requires three coordinated TypeScript patterns: discriminated unions for state representation, structured rejection metadata, and nominal branding for verified payloads. The implementation follows a strict progression.
Step 1: Define the Outcome Shape with Discriminated Unions
Replace the boolean flag with a union where each variant carries exactly the data it needs. The discriminant property (state) enables exhaustive narrowing.
type UploadState = 'verified' | 'rejected' | 'quarantined' | 'expired';
type UploadOutcome =
| { state: 'verified'; payload: CertifiedFile }
| { state: 'rejected'; reason: RejectionMetadata }
| { state: 'quarantined'; scanId: string; expiresAt: Date }
| { state: 'expired'; receivedAt: Date; scanId: string };
Why this works: TypeScript narrows the union based on state. Accessing payload is impossible without first checking outcome.state === 'verified'. The compiler eliminates the need for non-null assertions. Async states (quarantined, expired) are first-class citizens, not afterthoughts.
Step 2: Structure Rejection Metadata
Flattening errors to strings destroys context. Each rejection category should carry the exact data required for UI rendering, security logging, and automated remediation.
type RejectionCategory =
| 'size_exceeded'
| 'extension_blocked'
| 'magic_byte_mismatch'
| 'polyglot_detected'
| 'scanner_flagged';
type RejectionMetadata = {
category: RejectionCategory;
observed: unknown;
threshold?: number;
allowed?: readonly string[];
scanId?: string;
threatTags?: readonly string[];
};
Why this works: Consumers can switch on reason.category and access strongly-typed fields. A size violation includes observed and threshold. A magic byte mismatch includes the declared vs detected MIME types. Security telemetry receives structured objects instead of parsing "File too large". The readonly modifier prevents accidental mutation of validation results downstream.
Step 3: Apply Nominal Branding to Verified Payloads
TypeScript uses structural typing. A File that passed validation is indistinguishable from a raw File at the type level. Branding introduces nominal typing without runtime overhead.
declare const VERIFIED_BRAND: unique symbol;
type CertifiedFile = File & { readonly [VERIFIED_BRAND]: true
};
**Why this works:** The brand is a phantom property. It exists only in the type system. Functions that accept raw `File` objects will reject `CertifiedFile` unless explicitly updated. This prevents legacy code from accidentally consuming unverified uploads. The brand travels with the payload through the application, making the trust boundary visible in every function signature.
### Step 4: Enforce Exhaustiveness in Consumers
TypeScript does not automatically enforce that all union members are handled. You must explicitly assert exhaustiveness using the `never` type.
```typescript
function handleUploadOutcome(outcome: UploadOutcome): void {
switch (outcome.state) {
case 'verified':
processSecureUpload(outcome.payload);
break;
case 'rejected':
logRejection(outcome.reason);
break;
case 'quarantined':
schedulePolling(outcome.scanId, outcome.expiresAt);
break;
case 'expired':
notifyUserRetry(outcome.receivedAt);
break;
default:
const _exhaustiveCheck: never = outcome;
throw new Error(`Unhandled upload state: ${_exhaustiveCheck}`);
}
}
Why this works: If a new state is added to UploadOutcome, TypeScript assigns it to outcome in the default branch. The assignment to never fails at compile time, forcing developers to update every consumer. This turns validation evolution into a compiler-guided migration instead of a silent regression.
Pitfall Guide
1. The Non-Null Assertion Trap
Explanation: Developers bypass narrowing by writing outcome.payload! or outcome.reason!. This defeats the union's safety guarantees and reintroduces runtime crashes.
Fix: Configure strictNullChecks and noUncheckedIndexedAccess in tsconfig.json. Use ESLint rules like @typescript-eslint/strict-boolean-expressions to flag assertions. Rely exclusively on type guards or switch statements.
2. Branding Without Nominal Enforcement
Explanation: TypeScript's structural typing allows objects with matching shapes to satisfy branded types if the brand property is accidentally added or stripped.
Fix: Never expose the brand symbol publicly. Use declare const with unique symbol. Ensure the verifier function is the only place that constructs the branded type. Consider using satisfies to validate constructor outputs.
3. Exhaustiveness Check Omission
Explanation: Consumers use if/else chains instead of switch statements, or they omit the default branch entirely. New states slip through silently.
Fix: Mandate switch with default exhaustiveness checks in code reviews. Use TypeScript's --strict flag. Add a test that imports the union and verifies all branches are covered using ts-expect-error assertions.
4. Mutable Result Objects
Explanation: Consumers modify outcome.reason or outcome.payload after validation. This corrupts telemetry data and breaks audit trails.
Fix: Mark all result properties as readonly. Use Readonly<UploadOutcome> in function signatures. Freeze objects at runtime if they cross process boundaries.
5. Async State Neglect
Explanation: Treating validation as purely synchronous ignores malware scanning, quarantine windows, and async verification pipelines. Consumers assume rejected or verified are the only outcomes.
Fix: Model async states explicitly in the union. Provide polling or webhook handlers for quarantined states. Document state transitions in a diagram. Never coerce async states into synchronous booleans.
6. Error String Flattening
Explanation: Mapping structured rejections to generic UI strings too early. This loses thresholds, observed values, and threat categories needed for security analysis.
Fix: Keep rejections structured until the presentation layer. Create a dedicated RejectionFormatter module that converts RejectionMetadata to user-facing messages. Preserve raw objects for logging and telemetry.
7. Scope Leakage to Legacy Functions
Explanation: Passing CertifiedFile to older functions that expect File. The brand is ignored, and the trust boundary dissolves.
Fix: Update legacy signatures to accept File | CertifiedFile or refactor them to require CertifiedFile. Use adapter functions that explicitly unwrap or re-verify when bridging old and new code. Document the boundary in architecture decision records.
Production Bundle
Action Checklist
- Define outcome union with explicit discriminant and state-specific payloads
- Structure rejection metadata with category, observed values, and thresholds
- Apply nominal branding to verified payloads using
unique symbol - Implement exhaustiveness checks with
nevertype assertions in all consumers - Mark all result properties as
readonlyto prevent downstream mutation - Model async states (
quarantined,expired) as first-class union members - Add ESLint rules to block non-null assertions on validation outcomes
- Write integration tests that verify state transitions and exhaustiveness coverage
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Internal admin tool with low threat exposure | Boolean gate with runtime checks | Simplicity outweighs type safety overhead. Low security risk. | Low development time, higher long-term maintenance |
| Enterprise SaaS with strict compliance requirements | Discriminated union + branded types + exhaustiveness | Compiler enforces security boundary. Audit trails receive structured data. | Moderate initial setup, near-zero regression cost |
| High-throughput API with async malware scanning | Union with explicit async states + polling/webhook handlers | Synchronous validation cannot cover scanning pipelines. Async states prevent race conditions. | Higher infrastructure complexity, prevents data leakage |
| Legacy codebase migration | Adapter layer with gradual type narrowing | Direct replacement breaks existing consumers. Adapters preserve runtime behavior while introducing types. | Phased rollout cost, zero downtime migration |
Configuration Template
// types/upload-boundary.ts
declare const VERIFIED_BRAND: unique symbol;
export type CertifiedFile = File & { readonly [VERIFIED_BRAND]: true };
export type RejectionCategory =
| 'size_exceeded'
| 'extension_blocked'
| 'magic_byte_mismatch'
| 'polyglot_detected'
| 'scanner_flagged';
export type RejectionMetadata = {
category: RejectionCategory;
observed: unknown;
threshold?: number;
allowed?: readonly string[];
scanId?: string;
threatTags?: readonly string[];
};
export type UploadOutcome =
| { state: 'verified'; payload: CertifiedFile }
| { state: 'rejected'; reason: RejectionMetadata }
| { state: 'quarantined'; scanId: string; expiresAt: Date }
| { state: 'expired'; receivedAt: Date; scanId: string };
// verifier/upload-verifier.ts
import type { CertifiedFile, UploadOutcome, RejectionMetadata } from '../types/upload-boundary';
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_EXTENSIONS = ['pdf', 'png', 'jpg'] as const;
export function verifyUpload(rawFile: File): UploadOutcome {
if (rawFile.size > MAX_BYTES) {
return {
state: 'rejected',
reason: {
category: 'size_exceeded',
observed: rawFile.size,
threshold: MAX_BYTES,
},
};
}
const ext = rawFile.name.split('.').pop()?.toLowerCase();
if (!ext || !ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) {
return {
state: 'rejected',
reason: {
category: 'extension_blocked',
observed: ext ?? 'none',
allowed: ALLOWED_EXTENSIONS,
},
};
}
// In production, integrate async scanner here.
// For sync demonstration, we return verified with brand injection.
const certified = rawFile as CertifiedFile;
return { state: 'verified', payload: certified };
}
// consumers/upload-handler.ts
import type { UploadOutcome } from '../types/upload-boundary';
export function processOutcome(outcome: UploadOutcome): void {
switch (outcome.state) {
case 'verified':
console.log('Secure upload ready:', outcome.payload.name);
break;
case 'rejected':
console.warn('Upload blocked:', outcome.reason.category, outcome.reason.observed);
break;
case 'quarantined':
console.log('Awaiting scan:', outcome.scanId, 'Expires:', outcome.expiresAt);
break;
case 'expired':
console.error('Scan timeout:', outcome.scanId, 'Received:', outcome.receivedAt);
break;
default:
const _check: never = outcome;
throw new Error(`Unhandled state: ${_check}`);
}
}
Quick Start Guide
- Define the boundary types: Create
upload-boundary.tswith the discriminated union, rejection metadata, and branded payload type. - Implement the verifier: Write
verifyUploadto returnUploadOutcome. Inject the brand only after all checks pass. - Add exhaustiveness to consumers: Replace
if/elsevalidation checks withswitchstatements. Include thedefaultbranch withnevertype assertion. - Enforce strict mode: Enable
strictNullChecks,strictFunctionTypes, andnoUncheckedIndexedAccessintsconfig.json. Add ESLint rules to block non-null assertions on validation results. - Test state coverage: Write a test that imports
UploadOutcomeand verifies all branches are handled. Usets-expect-errorto confirm the compiler catches missing cases when new states are added.
