Application Security

TypeScript Security Best Practices

How TypeScript's type system helps catch security bugs at compile time, and what it cannot protect you from.

James
Senior Security Engineer
5 min read

TypeScript adds a type system on top of JavaScript. That type system can prevent entire classes of security bugs, but only if you use it deliberately. TypeScript does not make your code secure by default. It gives you tools to express and enforce security constraints at compile time.

What TypeScript's Type System Can Catch

Injection Prevention with Branded Types

TypeScript's type system can distinguish between raw user input and sanitized data using branded types:

type SanitizedHTML = string & { __brand: 'SanitizedHTML' };
type RawUserInput = string;

function sanitizeHTML(input: RawUserInput): SanitizedHTML {
  // Perform actual sanitization
  const clean = DOMPurify.sanitize(input);
  return clean as SanitizedHTML;
}

function renderHTML(html: SanitizedHTML): void {
  element.innerHTML = html;
}

// This works
const clean = sanitizeHTML(userInput);
renderHTML(clean);

// This fails at compile time - raw string is not SanitizedHTML
renderHTML(userInput); // Type error!

This pattern ensures that raw user input cannot reach dangerous sinks like innerHTML without passing through sanitization. The brand is erased at runtime (zero performance cost) but enforced at compile time.

SQL Injection Prevention

Apply the same pattern to SQL queries:

type SafeSQL = string & { __brand: 'SafeSQL' };

function parameterizedQuery(
  template: TemplateStringsArray,
  ...params: (string | number)[]
): SafeSQL {
  // Build parameterized query
  return buildQuery(template, params) as SafeSQL;
}

function executeQuery(sql: SafeSQL): Promise<Result> {
  return db.raw(sql);
}

// Safe: uses parameterized template
const query = parameterizedQuery`SELECT * FROM users WHERE id = ${userId}`;
executeQuery(query);

// Unsafe: raw string concatenation fails type check
executeQuery(`SELECT * FROM users WHERE id = ${userId}`); // Type error!

Exhaustive Checks with Discriminated Unions

TypeScript's discriminated unions with exhaustive checks prevent missed cases in security-critical control flow:

type Permission = 'admin' | 'editor' | 'viewer';

function getAccessLevel(permission: Permission): AccessLevel {
  switch (permission) {
    case 'admin': return AccessLevel.Full;
    case 'editor': return AccessLevel.Write;
    case 'viewer': return AccessLevel.Read;
    default:
      const _exhaustive: never = permission;
      throw new Error(`Unhandled permission: ${_exhaustive}`);
  }
}

If a new permission type is added, the never check will cause a compile error, forcing you to handle it explicitly.

What TypeScript Cannot Catch

Runtime Type Safety

TypeScript types are erased at compile time. Data from external sources (API responses, user input, file reads) has no type guarantees at runtime:

interface UserData {
  id: number;
  name: string;
  role: 'admin' | 'user';
}

// TypeScript trusts this cast, but the data could be anything
const user = JSON.parse(body) as UserData;

Use runtime validation libraries like Zod or io-ts:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  role: z.enum(['admin', 'user']),
});

type UserData = z.infer<typeof UserSchema>;

const user = UserSchema.parse(JSON.parse(body)); // Throws on invalid data

Type Assertions and any

any disables type checking. Every use of any is a security hole in your type system:

// Bad: defeats the purpose of TypeScript
function processInput(data: any): void {
  db.query(data.sql); // No type checking at all
}

// Good: explicit types
function processInput(data: { sql: SafeSQL }): void {
  db.query(data.sql);
}

Configure your tsconfig.json strictly:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true
  }
}

noUncheckedIndexedAccess is particularly valuable. It forces you to handle the case where array or object indexing returns undefined:

const items: string[] = ['a', 'b', 'c'];
const item = items[5]; // Type: string | undefined (must check before using)

Prototype Pollution

TypeScript does not prevent prototype pollution. If you use Object.assign, spread operators, or JSON.parse with attacker-controlled data, you are still vulnerable:

// Vulnerable even with TypeScript
const config = { ...defaultConfig, ...JSON.parse(userInput) };
// userInput could contain __proto__ or constructor properties

Use Object.create(null) for dictionaries and validate input shapes.

ESLint Security Rules for TypeScript

Configure @typescript-eslint with security-focused rules:

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-unsafe-assignment": "error",
    "@typescript-eslint/no-unsafe-member-access": "error",
    "@typescript-eslint/no-unsafe-call": "error",
    "@typescript-eslint/no-unsafe-return": "error",
    "no-eval": "error",
    "no-implied-eval": "error"
  }
}

The no-unsafe-* rules catch places where any types leak through your codebase.

Dependency Types and Security

TypeScript's @types packages (DefinitelyTyped) are a separate supply chain concern. When you install @types/express, you are trusting a community-maintained type definition package that is separate from the express package itself. A compromised @types package could include malicious postinstall scripts or provide incorrect type definitions that hide security issues.

Pin @types packages like any other dependency. Review changes to type definition packages in your lockfile.

SBOM and Audit

TypeScript projects use the same npm/yarn/pnpm ecosystem for SBOM generation. Your package-lock.json is the source of truth:

npx @cyclonedx/cyclonedx-npm --output-file sbom.json

How Safeguard.sh Helps

Safeguard.sh monitors your TypeScript project dependencies with the same rigor as any JavaScript project, since they share the npm ecosystem. It tracks your full dependency tree including @types packages, correlates against vulnerability databases, and surfaces risks across your TypeScript services. Safeguard.sh integrates into your CI pipeline to catch vulnerable dependencies before they reach production, giving your team a clear view of supply chain risk across every TypeScript service.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.