ry 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.
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.
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
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.ts with the discriminated union, rejection metadata, and branded payload type.
- Implement the verifier: Write
verifyUpload to return UploadOutcome. Inject the brand only after all checks pass.
- Add exhaustiveness to consumers: Replace
if/else validation checks with switch statements. Include the default branch with never type assertion.
- Enforce strict mode: Enable
strictNullChecks, strictFunctionTypes, and noUncheckedIndexedAccess in tsconfig.json. Add ESLint rules to block non-null assertions on validation results.
- 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.