Back to KB
Difficulty
Intermediate
Read Time
9 min

Type Your File Validation Library as a Security Boundary

By Codcompass Team··9 min read

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.

ApproachCompile-Time EnforcementError GranularityConsumer ExhaustivenessSecurity Posture
Boolean Gate + Optional PayloadNone. 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 TypesStrict. 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 never type assertions in all consumers
  • Mark all result properties as readonly to 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

ScenarioRecommended ApproachWhyCost Impact
Internal admin tool with low threat exposureBoolean gate with runtime checksSimplicity outweighs type safety overhead. Low security risk.Low development time, higher long-term maintenance
Enterprise SaaS with strict compliance requirementsDiscriminated union + branded types + exhaustivenessCompiler enforces security boundary. Audit trails receive structured data.Moderate initial setup, near-zero regression cost
High-throughput API with async malware scanningUnion with explicit async states + polling/webhook handlersSynchronous validation cannot cover scanning pipelines. Async states prevent race conditions.Higher infrastructure complexity, prevents data leakage
Legacy codebase migrationAdapter layer with gradual type narrowingDirect 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

  1. Define the boundary types: Create upload-boundary.ts with the discriminated union, rejection metadata, and branded payload type.
  2. Implement the verifier: Write verifyUpload to return UploadOutcome. Inject the brand only after all checks pass.
  3. Add exhaustiveness to consumers: Replace if/else validation checks with switch statements. Include the default branch with never type assertion.
  4. Enforce strict mode: Enable strictNullChecks, strictFunctionTypes, and noUncheckedIndexedAccess in tsconfig.json. Add ESLint rules to block non-null assertions on validation results.
  5. Test state coverage: Write a test that imports UploadOutcome and verifies all branches are handled. Use ts-expect-error to confirm the compiler catches missing cases when new states are added.