TypeScript's Honesty System: Why Type Safety is Optional and How to Enforce It

TypeScript's type system is an honesty system. Like an honesty bucket in a car park, it asks nicely but doesn't enforce anything. It provides zero actual runtime safety and can be trivially bypassed with escape hatches scattered throughout the language. Understanding this reality (and how to defend against it) is critical for maintaining type safety in production codebases.

The Honesty Bucket Analogy

Imagine a car park with an honesty bucket at the entrance. There's a sign that says "£5 per hour, please pay here." No barrier, no enforcement, no consequences for not paying. That's TypeScript.

TypeScript provides powerful static analysis to catch type errors at compile time, but it's built on JavaScript, a dynamically typed language. Every TypeScript file is transpiled to JavaScript, losing all type information in the process. The types exist only during development, and the compiler trusts you to be honest about them.

This isn't a flaw. It's by design. But it means TypeScript is better understood as advanced static analysis (like PHPStan for PHP or ESLint for JavaScript) rather than a true type system like you'd find in Rust or Haskell.

The Complete Bypass Taxonomy: 25+ Ways to Lie to TypeScript

TypeScript's type system can be bypassed in over 25 distinct ways. This comprehensive taxonomy documents every known mechanism, from obvious to obscure. Understanding these escape hatches is essential for recognising when codebases are being "dishonest" with the type system, and for defending against them.

Quick Reference: All Bypass Mechanisms

Level 1: Blatant Bypasses

  • any type - Nuclear option, disables all type checking
  • @ts-ignore - Suppresses next line error
  • @ts-nocheck - Disables checking for entire file

Level 2: Sneaky Bypasses

  • as T - Type assertions that force type coercion
  • as unknown as T - Double assertion pattern to bypass safety rails
  • @ts-expect-error - "Safer" ts-ignore but still a bypass
  • satisfies + as any - Combining safe operator with unsafe bypass

Level 3: Subtle Bypasses

  • JSON.parse() - Returns any by default
  • Object.assign() - Loses type information at runtime
  • Spread operators - Type inference may be wrong
  • Array/object destructuring - Can introduce any

Level 4: Structural Loopholes

  • Optional properties (?) - Properties can be missing
  • Index signatures - Allow arbitrary properties
  • Excess property checking bypass - Intermediate variable assignment

Level 5: Advanced Bypasses

  • declare - Ambient declarations bypass verification
  • Module augmentation - Add properties to third-party types

Level 6: Type System Manipulation

  • Type predicates (is) - Can lie about type narrowing
  • Generic <any> - Type parameters with any
  • Function overloads - Implementation can hide unsafe casts
  • Numeric enums - Accept any number (pre-TS 5.0)
  • void return abuse - Functions can return values
  • Constructor casting - Bypass instantiation checks

Level 7: Runtime Escape Mechanisms

  • eval() - Execute arbitrary code, returns any
  • new Function() - Function constructor, complete bypass
  • Bracket notation on private - Bypasses TypeScript private (not JavaScript #)
  • Object.setPrototypeOf() - Runtime type mutation
  • delete operator - Remove required properties
  • Recursive type limits - TypeScript gives up after ~50 iterations

The Bypass Hierarchy: From Obvious to Sneaky

Let's examine each bypass mechanism in detail, with code examples showing exactly how they defeat type safety:

Level 1: Blatant Bypasses

These are the "I give up" approaches that developers reach for when fighting with the compiler:

// Blatant bypasses - the "I give up" approach

// Using 'any' - turn off ALL type checking
const value: any = "hello";
value.nonExistentMethod(); // No error!

// @ts-ignore - ignore the next line
// @ts-ignore
const broken: number = "not a number";

// @ts-nocheck - disable checking for entire file
// @ts-nocheck at top of file disables all TypeScript errors

The any type is the nuclear option. It completely disables type checking for that value. @ts-ignore and @ts-nocheck tell the compiler to stop checking entirely. These are honest about their dishonesty.

Level 2: Sneaky Bypasses

Type assertions are where things get interesting. They look more legitimate but are equally dangerous:

// Sneaky bypasses - double assertion tricks

// The classic double assertion
const numAsString = 42 as unknown as string;

// or using 'any' as intermediate
const dateAsNumber = new Date() as any as number;

// Type assertions that "narrow" but lie
interface User {
  name: string;
  email: string;
}

const maybeUser = { name: "Alice" } as User; // Missing email!

The double assertion pattern (as unknown as T) is particularly insidious. TypeScript prevents "impossible" coercions, but by first asserting to unknown (the top type), you can then assert to anything. It's a two-step lie that bypasses the safety rails.

The @ts-expect-error Bypass

@ts-expect-error was introduced in TypeScript 3.9 as a "safer" alternative to @ts-ignore. It requires an error to exist, making it self-documenting. But it's still a bypass mechanism:

// @ts-expect-error - "safer" than @ts-ignore but still a bypass

// @ts-expect-error requires an error to exist
// @ts-expect-error
const invalid: number = "string"; // Valid usage - error expected

// But it's still a bypass mechanism
interface StrictAPI {
  endpoint: string;
  port: number;
  auth: { token: string };
}

// Use @ts-expect-error to bypass incomplete types
// @ts-expect-error - TODO: add auth later
const api: StrictAPI = {
  endpoint: "https://api.example.com",
  port: 443
}; // Missing 'auth' but no error

// DANGER: If code changes and error goes away, @ts-expect-error still suppresses
// @ts-expect-error - this used to be wrong but now it's fixed
const nowValid: string = "string"; // TypeScript won't warn this is unnecessary

// COMBINED with any for double bypass
// @ts-expect-error
const doubleBypass: number = ("string" as any);

The danger is that @ts-expect-error becomes outdated when code changes. If the error is fixed, TypeScript won't warn that the suppression is unnecessary. It's a time bomb in your codebase.

The satisfies Operator (TypeScript 4.9+)

The satisfies operator introduced in TypeScript 4.9 (August 2022) is actually safer than type assertions when used correctly. Unlike as, it validates types without overriding inference. However, it can be misused in combination with other bypasses:

// The satisfies operator (TypeScript 4.9+)
// Unlike 'as', satisfies is actually SAFER - it validates without overriding type
// However, it can still be misused in combination with other bypasses

interface Config {
  endpoint: string;
  port: number;
}

// CORRECT use - satisfies validates and preserves literal types
const config = {
  endpoint: "https://api.example.com",
  port: 3000
} satisfies Config;

// SNEAKY bypass - using satisfies with type assertions
const dodgyConfig = {
  endpoint: "not-a-url",
  port: "not-a-number" as unknown as number
} satisfies Config; // satisfies passes because we lied with 'as'

// COMBINED bypass - satisfies + any
const anySatisfies = {
  endpoint: "anything",
  random: "extra properties"
} as any satisfies Config; // Completely defeats the purpose

While satisfies itself strengthens type safety, developers can abuse it by combining it with as any or type assertions, creating a false sense of security.

Level 3: Subtle Bypasses

These are runtime operations that lose type information without explicit escape hatches:

// Subtle bypasses - losing types through runtime operations

interface StrictUser {
  id: number;
  name: string;
}

const user: StrictUser = { id: 1, name: "Bob" };

// Object.assign loses type safety
const merged = Object.assign({}, user, { extra: "field" });
// merged is now typed as StrictUser & { extra: string } but only at compile time

// Spread operators can add properties TypeScript doesn't track
const expanded = { ...user, unexpected: true };

// JSON.parse returns 'any' by default
const jsonUser: StrictUser = JSON.parse('{"id": 1, "name": "Carol"}');
// What if the JSON is malformed or missing fields? TypeScript doesn't know!

// DOM manipulation loses type information
const element = document.getElementById("user-data");
const userData = JSON.parse(element!.textContent!); // any type!

Object.assign(), spread operators, and JSON.parse() all operate at runtime on plain JavaScript objects. TypeScript can infer types at compile time, but it can't verify them at runtime. JSON.parse() is particularly dangerous. It returns any by default, creating a massive hole in type safety.

Level 4: Structural Loopholes

TypeScript's structural type system has built-in flexibility that can be exploited:

// Structural loopholes - TypeScript's flexibility becomes weakness

interface Config {
  apiUrl?: string; // Optional property
  timeout?: number;
}

// All of these are "valid" but potentially broken at runtime
const config1: Config = {}; // No properties at all
const config2: Config = { apiUrl: undefined }; // Explicitly undefined
const config3: Config = { timeout: null as any }; // Null masquerading as undefined

// Index signatures allow anything
interface FlexibleObject {
  [key: string]: any; // Escape hatch built into the type!
}

const flex: FlexibleObject = {
  anything: "goes",
  here: 123,
  evenThis: () => "functions!"
};

// Excess property checking can be bypassed
interface StrictConfig {
  host: string;
  port: number;
}

const intermediate = { host: "localhost", port: 3000, extra: "oops" };
const strictConfig: StrictConfig = intermediate; // No error!

Optional properties (?) mean a property can be missing entirely. Index signatures ([key: string]: any) allow arbitrary properties. TypeScript's excess property checking can be bypassed by assigning through an intermediate variable. These aren't bugs. They're features of a flexible structural type system. But they weaken safety guarantees.

Level 5: Advanced Bypasses

The most sophisticated bypasses use TypeScript's declaration system:

// Advanced bypasses - declare and module augmentation

// Declare tells TypeScript "trust me, this exists at runtime"
declare const magicValue: string;
console.log(magicValue); // No error, but will crash if not defined!

// Ambient declarations for third-party code
declare module "untyped-library" {
  export function doSomething(param: any): any; // any everywhere!
}

// Module augmentation to add properties
declare module "express" {
  interface Request {
    user?: any; // Adding 'any' typed properties
  }
}

// Global namespace pollution
declare global {
  var unsafeGlobal: any;

  interface Window {
    myCustomProperty: any;
  }
}

Ambient declarations (declare) tell TypeScript "this exists at runtime, trust me." Module augmentation allows adding properties to third-party types. These are legitimate features for integrating untyped code, but they're also escape hatches that bypass verification.

Level 6: Type System Manipulation

These bypasses exploit TypeScript's type system features to create unsafe code that looks type-safe:

Type Predicates - Lying Type Guards

Type predicates (is keyword) allow custom type guards. TypeScript trusts your logic without verification, creating a massive trust hole:

// Type predicates - custom type guards can lie
// TypeScript trusts your type predicate logic without verification

interface User {
  id: number;
  name: string;
}

// LYING type guard - returns true but doesn't actually check
function isUser(value: unknown): value is User {
  // Should check: typeof value === 'object' && 'id' in value && 'name' in value
  // But we're lazy...
  return true; // LIES! Accepts anything as a User
}

// BROKEN type guard - incorrect logic
function isValidNumber(value: unknown): value is number {
  return typeof value === "string"; // Wrong check - returns strings as numbers!
}

// ONE-SIDED type guard - false case is unsafe
function isBigNumber(value: string | number): value is number {
  return typeof value === "number" && value > 1000;
  // Problem: false means value could be string OR number <= 1000
  // TypeScript assumes false = definitely string (incorrect!)
}

// Runtime disaster waiting to happen
const maybeUser = { random: "data" };
if (isUser(maybeUser)) {
  console.log(maybeUser.name.toUpperCase()); // Runtime error!
}

Type predicates are particularly dangerous because they combine compile-time and runtime trust. TypeScript assumes your predicate logic is correct and narrows types based on it. If your predicate lies, runtime disasters follow.

Generic Type Parameters with any

Generic type parameters with any create complete type erasure:

// Generic type parameters with 'any' - complete type erasure

// UNSAFE generic with any
function processData<T = any>(data: T): T {
  // T is effectively 'any' by default
  return data;
}

const result = processData({ anything: "goes" }); // result is 'any'

// EXPLICIT any in generic
function dangerousTransform<T>(input: any): T {
  // Accepts anything, returns "anything as T"
  return input as T; // Double lie: any input, unchecked output
}

const fakeUser = dangerousTransform<{ id: number }>({ id: "not-a-number" });

// GENERIC constraint bypass
interface Validated<T extends object> {
  data: T;
}

// Bypass constraint with any
const invalid: Validated<any> = { data: "not an object" };

// GENERIC with unknown-as trick
function coerce<TOut>(input: unknown): TOut {
  return input as unknown as TOut; // The double assertion works in generics too
}

Generic constraints can be bypassed by passing any as the type argument, effectively disabling all type checking for that generic instantiation.

Function Overloads

Function overloads let you define multiple type signatures, but the implementation signature can hide unsafe casts:

// Function overloads - implementation signature can hide unsafe casts

// Public overload signatures look safe
function processValue(input: string): string;
function processValue(input: number): number;

// Implementation signature can cheat
function processValue(input: any): any {
  // We told TypeScript we'd return string|number
  // But implementation can return ANYTHING
  return { surprise: "object" }; // No error!
}

// This LOOKS safe but can explode at runtime
const result = processValue("test");
const upper = result.toUpperCase(); // Runtime error if result is object

// SNEAKY overload bypass
function transform(input: string, safe: true): string;
function transform(input: unknown, safe: false): unknown;
function transform(input: any, safe: boolean): any {
  if (!safe) {
    // Can do ANYTHING here, including unsafe casts
    return input as string; // Lie about the type
  }
  return String(input);
}

The public overload signatures look safe, but the implementation can do anything. TypeScript only checks that the implementation is compatible with the overloads, not that it's actually safe.

Enum Number Assignment

Numeric enums had a major type safety flaw before TypeScript 5.0. They accepted any number value, not just defined enum members:

// Enum bypasses - numeric enums accept ANY number value

enum Status {
  Active = 1,
  Inactive = 2,
  Pending = 3
}

// BEFORE TypeScript 5.0 - completely unsafe
let status: Status = 999; // No error! (TS 4.x)
let invalid: Status = -1;  // Also accepted (TS 4.x)

// TypeScript 5.0+ improved this, but string coercion still works
enum StringStatus {
  Active = "active",
  Inactive = "inactive"
}

// Still unsafe with type assertions
let fakeStatus = "random" as unknown as StringStatus;

// ENUM reverse mapping exploit
enum Direction {
  Up = 1,
  Down = 2
}

// Numeric enums create reverse mappings
const directionName = Direction[999]; // Returns undefined, but type is 'string'

// BITFLAG enum bypass - intentional design allows arbitrary numbers
enum Permissions {
  Read = 1,
  Write = 2,
  Execute = 4
}

// This is INTENDED for bitflags
let permissions: Permissions = 7; // Read | Write | Execute (TS 4.x)
// But it also allows nonsense
let bogus: Permissions = 12345; // Also accepted (TS 4.x)

TypeScript 5.0 (released March 2023) improved numeric enum safety significantly, but they can still be bypassed with type assertions. String enums are safer, but both can be coerced with as unknown as.

Void Return Type Abuse

TypeScript's void return type has surprising behaviour. Functions typed as returning void can actually return values:

// Void return type abuse - functions typed 'void' can return anything

type VoidCallback = () => void;

// This function returns a value despite void type!
const callback: VoidCallback = () => {
  return 42; // No error! TypeScript allows this
};

// The return value exists but is ignored by TypeScript
const result = callback(); // result type is 'void', but runtime value is 42

// DANGEROUS with async functions
type AsyncVoid = () => Promise<void>;

const asyncCallback: AsyncVoid = async () => {
  return { data: "surprise" }; // Allowed! Returns object despite Promise<void>
};

// Array methods demonstrate the "feature"
const numbers = [1, 2, 3];
const results: void[] = numbers.map(() => {
  return "string"; // Returns strings, typed as void[]
});

// EXPLICIT void annotation prevents returns
function explicitVoid(): void {
  return 42; // Error: Type 'number' is not assignable to type 'void'
}

// But contextual void typing allows it
const contextualVoid: () => void = () => 42; // No error!

This is intentional for function assignability (e.g., passing functions that return values to Array.forEach), but it means void doesn't guarantee no return value. It only means the return value is ignored by TypeScript.

Constructor Type Casting

Using constructor signatures with type assertions can bypass proper instantiation checks:

// Constructor type casting - using 'new' to bypass checks

interface IWidget {
  render(): void;
}

class Widget implements IWidget {
  private data: string;

  constructor(data: string) {
    this.data = data;
  }

  render(): void {
    console.log(this.data);
  }
}

// CONSTRUCTOR cast - bypassing proper instantiation
type WidgetConstructor = new (data: string) => IWidget;

const FakeWidget = Widget as unknown as WidgetConstructor;
const widget = new FakeWidget("test"); // Bypasses constructor checking

// NEWABLE type bypass
interface Newable<T> {
  new (...args: any[]): T;
}

function createInstance<T>(constructor: Newable<T>): T {
  return new constructor(); // args is 'any' - complete bypass
}

// ABSTRACT class instantiation bypass
abstract class AbstractBase {
  abstract doThing(): void;
}

// Can't instantiate abstract class normally
// const base = new AbstractBase(); // Error

// But we can bypass with casting
const FakeBase = AbstractBase as unknown as new () => AbstractBase;
const sneaky = new FakeBase(); // No error! (will fail at runtime)

Constructor casts can even instantiate abstract classes, which should be impossible. This fails at runtime but passes type checking.

Level 7: Runtime Escape Mechanisms

These bypasses completely escape TypeScript's static analysis by operating at runtime:

eval() and Function Constructor

eval() and the Function constructor execute arbitrary code at runtime, completely bypassing type checking:

// Runtime escape hatches - eval, Function constructor, private field access

class SecureData {
  private secretKey: string = "super-secret";
  #reallyPrivate: string = "javascript-private";

  getSecret(): string {
    return this.secretKey;
  }
}

const data = new SecureData();

// BRACKET NOTATION bypasses TypeScript private (but not JavaScript #private)
const stolen = data["secretKey"]; // Bypasses 'private' keyword at runtime
console.log(stolen); // "super-secret"

// JavaScript private (#) cannot be accessed this way
// data["#reallyPrivate"] // Error: not found

// EVAL - complete runtime escape from type system
const userInput = '({ malicious: "code" })';
const evil = eval(userInput); // Type is 'any', contains anything

// FUNCTION CONSTRUCTOR - dynamic code execution
const FunctionConstructor = Function;
const dynamicFunc = new FunctionConstructor('return { unsafe: true }');
const unsafe = dynamicFunc(); // Returns 'any'

// PROTOTYPE MANIPULATION - runtime type changes
interface Fixed {
  value: number;
}

const obj: Fixed = { value: 42 };
// At runtime, JavaScript allows this
Object.setPrototypeOf(obj, { value: "not a number anymore" });

// DELETE operator - removes required properties at runtime
interface Required {
  id: number;
  name: string;
}

const required: Required = { id: 1, name: "Test" };
delete (required as any).id; // Now missing 'id' despite type

These mechanisms return any and can contain literally anything. They're the nuclear option for bypassing TypeScript, but also create security vulnerabilities and performance issues.

Private Field Bypassing

TypeScript's private keyword is only enforced at compile time. At runtime, bracket notation bypasses private fields entirely:

However, JavaScript's private fields (#fieldName) introduced in ES2022 provide true runtime privacy that cannot be bypassed. The # syntax creates fields that are genuinely inaccessible from outside the class.

Prototype Manipulation

JavaScript's prototype system allows runtime type changes that TypeScript can't prevent. Object.setPrototypeOf() and the delete operator can mutate objects in ways that violate their types.

Recursive Type Limits

TypeScript has a hard recursion limit of approximately 50 type instantiations. When hit, TypeScript gives up and allows anything:

// Recursive type limits - hitting recursion limit disables checking

// TypeScript has a hard limit of ~50 recursive type instantiations
// When hit, type checking effectively gives up

type DeepNesting<T, Depth extends number = 0> =
  Depth extends 50 ? any : // After 50 levels, returns 'any'
  { nested: DeepNesting<T, Inc<Depth>> };

type Inc<N extends number> = [never, 0, 1, 2, 3, 4, 5][N];

// This type is so complex TypeScript gives up checking it
type InfiniteRecursion<T> = T extends any
  ? { [K in keyof T]: InfiniteRecursion<T[K]> }
  : never;

// EXPLOIT: Complex recursive types disable checking
type Json = string | number | boolean | null | JsonObject | JsonArray;
type JsonObject = { [key: string]: Json };
type JsonArray = Json[];

// TypeScript can't deeply validate this without hitting limits
const deepJson: Json = {
  level1: {
    level2: {
      level3: {
        // ... 50+ levels deep
        // Eventually TypeScript stops checking and allows anything
      }
    }
  }
};

// PRACTICAL exploit - deeply nested generics
type DeepPartial<T> = {
  [P in keyof T]?: DeepPartial<T[P]>;
};

// After enough nesting, TypeScript essentially treats this as 'any'
type UncheckedDeep = DeepPartial<DeepPartial<DeepPartial</* ... 50x */>>>;

Complex recursive types like DeepPartial or deeply nested JSON structures can hit this limit. There's no compiler flag to increase or disable the limit. It's a hard-coded safeguard against infinite recursion.

Why This Matters: The Similarity to PHPStan and ESLint

TypeScript's "honesty system" approach isn't unique. It's remarkably similar to other static analysis tools in the ecosystem:

  • PHPStan - PHP's static analyser has @phpstan-ignore-line and @phpstan-ignore-next-line for suppressing errors. It's static analysis with opt-out rules.
  • ESLint - JavaScript's linter has eslint-disable comments to bypass rules. It enforces code quality but allows developers to override.
  • Mypy - Python's type checker has # type: ignore comments to silence warnings. Optional typing with escape hatches.

All of these tools share a common pattern: they analyse code statically and report problems, but developers can override them. They provide enormous value when used honestly, but they can't prevent dishonest developers from bypassing safety checks.

Compare this to languages with true runtime type safety:

  • Rust - The borrow checker is mandatory. You can use unsafe blocks, but they're explicit and limited.
  • Haskell - Type safety is enforced at runtime. You can't bypass the type system without using low-level FFI.
  • Java - Strongly typed at runtime with reflection as the only escape hatch (and even then, types exist at runtime).

TypeScript isn't in this category. It's compile-time only static analysis. It has more in common with linters and static analysers than true type systems.

The Real Problem: LLMs and Dishonest Developers

The honesty system breaks down when developers (or AI coding assistants) liberally use escape hatches to "make the red squiggles go away." This is particularly problematic with large language models generating code:

  • LLMs don't care about type safety - They'll happily insert as any to fix compiler errors, not understanding the runtime implications.
  • Junior developers under pressure - Tight deadlines encourage quick fixes like @ts-ignore rather than proper type design.
  • Legacy codebases - Gradual TypeScript adoption leads to liberal use of any to get things compiling.
  • Third-party library integration - Missing or incorrect @types packages force developers into type assertions.

The result? A codebase that looks type-safe but is riddled with holes. The type system becomes theatre, providing false confidence without actual safety.

The Defence: ESLint to the Rescue

The solution is to treat TypeScript like PHPStan—static analysis that must be hardened with strict enforcement rules. Enter typescript-eslint, a suite of ESLint rules specifically designed to enforce type safety.

Essential Rules to Enable

Level 1 Defences: Block Blatant Bypasses

@typescript-eslint/no-explicit-any

Bans the any type entirely. Forces developers to use unknown (which requires type narrowing) or proper type definitions.

@typescript-eslint/no-non-null-assertion

Bans the non-null assertion operator (!). Encourages optional chaining (?.) and proper null checks.

@typescript-eslint/ban-ts-comment

Bans @ts-ignore and @ts-nocheck comments. Configure to require descriptions for @ts-expect-error:

{
  "@typescript-eslint/ban-ts-comment": ["error", {
    "ts-expect-error": "allow-with-description",
    "ts-ignore": true,
    "ts-nocheck": true,
    "minimumDescriptionLength": 10
  }]
}

Level 2 Defences: Control Type Assertions

@typescript-eslint/consistent-type-assertions

Controls type assertion usage. Can be configured to ban assertions entirely (assertionStyle: "never") for maximum safety, or enforce as syntax only.

@typescript-eslint/no-unsafe-type-assertion

Introduced in typescript-eslint v8 (2025), this rule prevents unsafe type assertions including the as unknown as T pattern. Blocks assertions that aren't provably safe.

Level 3 Defences: Prevent any Contamination

@typescript-eslint/no-unsafe-assignment

Prevents assigning any typed values to variables. Catches cases where any spreads through the codebase from JSON.parse(), third-party libraries, or type assertions.

@typescript-eslint/no-unsafe-argument

Prevents passing any typed values as function arguments. Stops any from spreading through function calls, including generic type parameters.

@typescript-eslint/no-unsafe-return

Prevents returning any typed values from functions. Catches functions that claim to return specific types but actually return any.

@typescript-eslint/no-unsafe-member-access

Prevents accessing properties on any typed values. Stops chains like apiResponse.data.field where apiResponse is any.

@typescript-eslint/no-unsafe-call

Prevents calling any typed values as functions. Blocks unsafe function calls on untyped values.

Level 4 Defences: Runtime Escape Prevention

@typescript-eslint/no-implied-eval

Bans eval(), new Function(), and eval-like functions (setTimeout with strings). Prevents complete runtime type system escapes and blocks security vulnerabilities.

no-new-func (ESLint core)

Companion to no-implied-eval. Explicitly bans the Function constructor.

no-eval (ESLint core)

Bans eval() usage. Works alongside no-implied-eval for comprehensive coverage.

Level 5 Defences: Type System Integrity

@typescript-eslint/no-unnecessary-type-assertion

Detects type assertions that don't change the type. Indicates misunderstanding or defensive programming against TypeScript's inference.

@typescript-eslint/no-unnecessary-condition

With checkTypePredicates: true, validates type predicate logic to catch lying type guards:

{
  "@typescript-eslint/no-unnecessary-condition": ["error", {
    "checkTypePredicates": true
  }]
}
@typescript-eslint/prefer-enum-initializers

Requires explicit enum values. Prevents accidental numeric enum issues and makes enum values explicit and intentional.

@typescript-eslint/prefer-literal-enum-member

Requires enum members to be literal values. Prevents computed enum values that could introduce unexpected behaviour.

Basic ESLint Configuration

{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-non-null-assertion": "error",
    "@typescript-eslint/ban-ts-comment": [
      "error",
      {
        "ts-expect-error": "allow-with-description",
        "ts-ignore": true,
        "ts-nocheck": true,
        "ts-check": false,
        "minimumDescriptionLength": 10
      }
    ],
    "@typescript-eslint/consistent-type-assertions": [
      "error",
      {
        "assertionStyle": "as",
        "objectLiteralTypeAssertions": "never"
      }
    ]
  }
}

Strict ESLint Configuration

For maximum type safety, extend the strict configuration and enable all safety rules:

{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module",
    "project": "./tsconfig.json"
  },
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking",
    "plugin:@typescript-eslint/strict"
  ],
  "rules": {
    // Level 1: Block blatant bypasses
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-non-null-assertion": "error",
    "@typescript-eslint/ban-ts-comment": [
      "error",
      {
        "ts-expect-error": "allow-with-description",
        "ts-ignore": true,
        "ts-nocheck": true,
        "minimumDescriptionLength": 20
      }
    ],

    // Level 2: Control type assertions
    "@typescript-eslint/consistent-type-assertions": [
      "error",
      {
        "assertionStyle": "never"
      }
    ],
    "@typescript-eslint/no-unsafe-type-assertion": "error",

    // Level 3: Prevent 'any' contamination
    "@typescript-eslint/no-unsafe-assignment": "error",
    "@typescript-eslint/no-unsafe-argument": "error",
    "@typescript-eslint/no-unsafe-member-access": "error",
    "@typescript-eslint/no-unsafe-call": "error",
    "@typescript-eslint/no-unsafe-return": "error",

    // Level 4: Runtime escape prevention
    "@typescript-eslint/no-implied-eval": "error",
    "no-eval": "error",
    "no-new-func": "error",

    // Level 5: Type system integrity
    "@typescript-eslint/no-unnecessary-type-assertion": "error",
    "@typescript-eslint/no-unnecessary-condition": [
      "error",
      {
        "checkTypePredicates": true
      }
    ],
    "@typescript-eslint/prefer-enum-initializers": "error",
    "@typescript-eslint/prefer-literal-enum-member": "error"
  }
}

Hardening Your TypeScript Project

ESLint enforcement is only one piece of the puzzle. Comprehensive type safety requires a multi-layered approach:

1. Strict TypeScript Configuration

Enable strict mode and additional safety options in tsconfig.json:

{
  "compilerOptions": {
    /* Language and Environment */
    "target": "ES2024",
    "lib": ["ES2024"],

    /* Modules */
    "module": "NodeNext",
    "moduleResolution": "NodeNext",

    /* Type Checking - Core Strict Mode */
    "strict": true,

    /* Type Checking - Additional Strict Options */
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true,

    /* Type Checking - Extra Safety */
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true,

    /* Completeness */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,

    /* Interop Constraints */
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  }
}

Key options to understand:

2. Runtime Validation with Type Guards

TypeScript types disappear at runtime. Use type guards for runtime validation to bridge this gap:

// Type guards - the honest way to narrow types

interface User {
  id: number;
  name: string;
  email: string;
}

// Runtime type guard
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as any).id === "number" &&
    "name" in value &&
    typeof (value as any).name === "string" &&
    "email" in value &&
    typeof (value as any).email === "string"
  );
}

// Using the type guard
function processUserData(data: unknown): void {
  if (isUser(data)) {
    // TypeScript KNOWS data is User here
    console.log(`User: ${data.name} (${data.email})`);
  } else {
    console.error("Invalid user data");
  }
}

// Parse JSON safely
function parseUser(json: string): User | null {
  try {
    const parsed: unknown = JSON.parse(json);
    return isUser(parsed) ? parsed : null;
  } catch {
    return null;
  }
}

For complex validation, consider runtime schema validation libraries like Zod, io-ts, or Ajv:

// Using Zod for runtime validation + type safety

import { z } from "zod";

// Define schema - this is both runtime validator AND type definition
const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "guest"]).optional(),
});

// Extract TypeScript type from schema
type User = z.infer<typeof UserSchema>;

// Parse JSON with runtime validation
function parseUserSafely(json: string): User | null {
  try {
    const data = JSON.parse(json);
    return UserSchema.parse(data); // Throws if invalid
  } catch (error) {
    console.error("Validation failed:", error);
    return null;
  }
}

// Safe parse (returns result object instead of throwing)
function parseUserResult(json: string) {
  try {
    const data = JSON.parse(json);
    const result = UserSchema.safeParse(data);

    if (result.success) {
      // result.data is typed as User
      return result.data;
    } else {
      console.error("Validation errors:", result.error.errors);
      return null;
    }
  } catch {
    return null;
  }
}

Zod is elegant because the schema is both the runtime validator and the compile-time type definition. You maintain a single source of truth that works at both compile time and runtime.

3. CI/CD Enforcement

Local development relies on developer discipline. CI/CD removes that dependency by making builds fail on violations:

name: TypeScript Type Safety Enforcement

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  type-safety:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: TypeScript strict compilation
        run: npm run type-check

      - name: ESLint type safety rules
        run: npm run lint -- --max-warnings=0

      - name: Check for 'any' usage
        run: |
          if grep -r ":\s*any" src/ --include="*.ts" --include="*.tsx"; then
            echo "Error: Found 'any' type usage in source files"
            exit 1
          fi

      - name: Check for @ts-ignore comments
        run: |
          if grep -r "@ts-ignore" src/ --include="*.ts" --include="*.tsx"; then
            echo "Error: Found @ts-ignore comments"
            exit 1
          fi

This GitHub Actions workflow blocks merging code that:

  • Fails strict TypeScript compilation
  • Has ESLint errors or warnings
  • Contains any types in source files
  • Uses @ts-ignore comments

4. Code Review Processes

Automation catches most issues, but human review is still essential:

  • Flag type assertions - Question every as assertion. Is it truly necessary?
  • Scrutinise @ts-expect-error - Valid use cases exist, but they should be rare and well-documented.
  • Review ambient declarations - declare statements bypass all type checking. Ensure they're accurate.
  • Check JSON parsing - Ensure JSON.parse() calls are validated with type guards or schema validators.

5. Third-Party Library Hygiene

Untyped or poorly-typed third-party libraries are a major source of any contamination:

  • Prefer typed libraries - Check for @types packages on npm.
  • Write your own type definitions - Use declaration files for untyped libraries.
  • Isolate untyped code - Create a typed wrapper around poorly-typed libraries to contain the any.
  • Audit dependencies - Use tools like type-coverage to measure type safety across dependencies.

The Philosophy: Assume Nothing, Verify Everything

TypeScript types are compile-time suggestions, not runtime guarantees. True type safety requires a defence-in-depth strategy:

  1. Strict TypeScript configuration - Enable every safety option.
  2. ESLint enforcement - Ban escape hatches programmatically.
  3. Runtime validation - Verify types at system boundaries (API responses, user input, external data).
  4. CI/CD gates - Block merging unsafe code.
  5. Cultural discipline - Treat type safety violations as bugs, not shortcuts.

This is exactly how you'd approach PHPStan in a PHP project:

  • Start with level 9 (maximum) strictness.
  • Disable @phpstan-ignore comments in code reviews.
  • Use baseline files for legacy code, never for new code.
  • Run PHPStan in CI and fail builds on violations.

The same principles apply to TypeScript. It's a powerful tool when wielded with discipline, but it's not magic. It won't save you from yourself.

Conclusion: TypeScript is Powerful, But Requires Discipline

This article has documented over 25 distinct ways to bypass TypeScript's type system, from the obvious (any, @ts-ignore) to the obscure (recursive type limits, constructor casting). TypeScript's "honesty system" is both a strength and a weakness. The flexibility that makes it easy to adopt gradually is the same flexibility that makes it easy to bypass completely.

The key takeaways:

  • TypeScript is static analysis, not runtime type safety - Treat it like PHPStan or ESLint, not Rust or Haskell.
  • Bypass mechanisms are everywhere - There are 7 distinct categories of bypasses, from blatant to runtime escapes. Developers and LLMs can trivially defeat type safety in dozens of ways.
  • Enforcement requires multi-layered defence - ESLint rules (15+ essential rules), strict tsconfig options, runtime validation with Zod/io-ts, and CI/CD gates are all necessary.
  • CI/CD is your safety net - Don't rely on developer discipline alone. Automate enforcement in your pipeline to catch bypasses before they reach production.
  • Cultural discipline matters - Type safety is a practice, not a feature. It requires team buy-in, code review vigilance, and rejection of "just add as any" shortcuts.
  • Runtime escapes exist - eval(), Function constructor, and prototype manipulation completely bypass static analysis. ESLint rules can ban them, but awareness is critical.

Without enforcement, TypeScript is just suggestions. With proper hardening (strict configuration, 15+ ESLint rules covering all 7 bypass categories, runtime validation at boundaries, and CI enforcement), it becomes a powerful tool for building maintainable, type-safe applications. But it's never foolproof, and it's never automatic.

The honesty bucket only works if everyone pays. Make sure your team - and your tooling - holds everyone accountable. Now that you've seen all 25+ ways to bypass TypeScript, you can defend against them comprehensively. Ignorance is no longer an excuse.

Further Reading

TypeScript Official Resources

TypeScript ESLint and Tooling

Runtime Validation Libraries

  • Zod - TypeScript-first schema validation with static type inference
  • io-ts - Runtime type system for validating unknown data
  • Ajv - JSON Schema validator with TypeScript support
  • Yup - Schema validation library with TypeScript types
  • TypeBox - JSON Schema Type Builder with static type resolution

Books and Learning Resources

Related Articles on Type Safety