CodeFixesHub
    programming tutorial

    Typing Functions That Use eval() — A Cautionary Tale

    Learn safe patterns for typing functions that call eval in TypeScript. Reduce risks, validate results, and adopt safer alternatives—read the guide.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 3
    Published
    20
    Min Read
    2K
    Words
    article summary

    Learn safe patterns for typing functions that call eval in TypeScript. Reduce risks, validate results, and adopt safer alternatives—read the guide.

    Typing Functions That Use eval() — A Cautionary Tale

    Introduction

    Using eval() in JavaScript is often described as a last-resort tool. It dynamically executes strings as code, which can be powerful but dangerous. When you combine eval with TypeScript, the interplay becomes even more subtle: TypeScript's static types give a false sense of safety if your runtime is still executing arbitrary strings. This article explores how to design, type, validate, and test functions that use eval() safely in TypeScript, emphasizing practical patterns, trade-offs, and alternatives for intermediate developers.

    In this tutorial you will learn: how to define precise function signatures for wrappers around eval(), strategies to narrow and validate eval outputs at runtime, patterns to avoid spreading any and unchecked assertions, how to integrate eval safely with async code, testing and sandboxing approaches, and when to avoid eval altogether. We'll cover specific TypeScript techniques, code examples, type guards, and performance considerations so you can make informed decisions in real-world systems.

    This is not a justification for littering your codebase with eval. Instead, it's a pragmatic guide for situations where eval is unavoidable (legacy integrations, user-defined formulas, or embed engines) and you need to keep the rest of the codebase typed and safe.

    Background & Context

    eval() takes a string and executes it as code in the current scope. Because the code executed is determined at runtime, TypeScript cannot analyze or infer types for that code ahead of time. That means untyped results, potential runtime errors, and security vulnerabilities if the input is untrusted. Understanding how to bridge static typing and dynamic evaluation is crucial to maintain safety, DX, and runtime reliability.

    We'll examine strategies that combine TypeScript types, runtime validation, and architecture to keep eval usage contained. These include typed wrapper functions, type predicates and guards, runtime schemas, sandboxing, and safe alternatives like interpreters or expression evaluators.

    Key Takeaways

    • Treat eval outputs as untrusted: always validate and narrow them before use.
    • Use well-typed wrapper APIs to contain eval usage and provide clear contracts for callers.
    • Prefer interpreters, AST-based evaluators, or restricted sandboxes when possible.
    • Use type predicates and runtime validators to convert unknown into safe types.
    • Avoid the blanket use of any and type assertions; prefer narrow, validated types.
    • Test eval behavior extensively with unit and integration tests.

    Prerequisites & Setup

    This guide assumes you know TypeScript 4.x basics, generics, and type guards. You'll need Node.js and a TypeScript project scaffolded (tsconfig set to strict mode recommended). Install a runtime validator if you prefer a library-based approach: for example, ajv, zod, or io-ts. Example installations:

    bash
    npm init -y
    npm install typescript --save-dev
    npm install zod
    npx tsc --init

    All code examples use modern TypeScript (strict mode) and minimal dependencies so you can adapt them to your environment.

    Main Tutorial Sections

    1) Why eval() is risky and what TypeScript can't protect

    eval executes dynamically generated code so the compiler cannot see the expressions you run. That means a function that returns a value from eval will look like it returns any or unknown at runtime. If you rely on TypeScript types alone, you risk runtime exceptions and security holes. For discussion on how certain unsafe patterns propagate, see our guide on Security Implications of Using any and Type Assertions in TypeScript.

    Practical advice: treat eval output as unknown initially and force a validation/narrowing step before relying on its shape.

    2) When eval shows up in real projects

    Common scenarios include: user-defined formulas (spreadsheet-like apps), plugin engines, templating, legacy code migration, or executing user-provided scripts in an isolated environment. In configuration-driven systems, eval may be used to evaluate expressions embedded in config files. When that happens, you should combine static typing for the configuration object with safe evaluation of expression fields—see patterns in Typing Configuration Objects in TypeScript: Interfaces vs Type Aliases — An Intermediate Guide.

    The rule: keep the surface area of eval small. Only evaluate what you must and wrap it behind a typed, documented API.

    3) Designing a typed wrapper around eval()

    Create a wrapper function whose type signature expresses that its output must be validated. Prefer returning Result-like objects rather than throwing raw exceptions. Example:

    ts
    type EvalResult<T> = { ok: true; value: T } | { ok: false; error: Error };
    
    function safeEval<T = unknown>(expr: string, context: Record<string, unknown> = {}): EvalResult<T> {
      try {
        const fn = new Function(...Object.keys(context), `return (${expr});`);
        const result = fn(...Object.values(context));
        return { ok: true, value: result as T };
      } catch (err) {
        return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
      }
    }

    Notes: avoid global eval when possible; using Function constructor isolates scope a bit. The generic T signals the developer's intent, but remember: T is only meaningful after runtime validation.

    For handling variable argument lists or more dynamic calls, combine generics with rest parameters—see patterns in Typing Functions with Variable Number of Arguments (Rest Parameters Revisited).

    4) Narrowing eval results with type guards and predicates

    Once you get a runtime value back, use type predicates or validators to narrow it. Either write custom type predicates or use a validation library. Type predicates let the compiler understand narrowed types.

    Example custom guard:

    ts
    function isNumber(x: unknown): x is number {
      return typeof x === 'number' && Number.isFinite(x);
    }
    
    const res = safeEval<number>('1 + 2');
    if (res.ok && isNumber(res.value)) {
      // TypeScript now knows res.value is number
      console.log(res.value.toFixed(2));
    }

    If you need structured types, consider schema validators. Learn advanced guard patterns in Using Type Predicates for Custom Type Guards.

    5) Handling multiple possible return types (unions) from eval

    Often expressions can yield multiple shapes (number | string | object). Model that with union return types and narrow at runtime, or use discriminated unions.

    Example:

    ts
    type EvalOutput = { type: 'num'; value: number } | { type: 'str'; value: string };
    
    function normalizeEvalOutput(x: unknown): EvalOutput | null {
      if (typeof x === 'number') return { type: 'num', value: x };
      if (typeof x === 'string') return { type: 'str', value: x };
      return null;
    }

    For complex scenarios, read about functions that return multiple types and safe patterns in Typing Functions with Multiple Return Types (Union Types Revisited).

    6) Async eval: Promise-returning expressions

    If eval expressions produce asynchronous results (e.g., return fetch calls or async functions), you must handle Promises and their typed resolution. Wrap eval invocations in async functions and annotate expected result types.

    Example:

    ts
    async function safeEvalAsync<T = unknown>(expr: string, ctx: Record<string, unknown> = {}): Promise<EvalResult<T>> {
      try {
        const fn = new Function(...Object.keys(ctx), `return (async () => { return (${expr}); })();`);
        const value = await fn(...Object.values(ctx));
        return { ok: true, value: value as T };
      } catch (err) {
        return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
      }
    }

    Make sure to annotate async expectations and validate resolved values. For patterns about typing promises that resolve to different types, check Typing Promises That Resolve with Different Types.

    7) Sandboxing and restricted evaluation patterns

    If eval is necessary, limit global access and allowed operations. The most reliable approach is not eval but to parse expressions into an AST and execute them with a restricted interpreter. If you must evaluate, create a restricted context and use the Function constructor with explicit allowed bindings.

    Example restricted runner:

    ts
    function restrictedEval(expr: string, allowed: Record<string, unknown>) {
      const keys = Object.keys(allowed);
      const vals = Object.values(allowed);
      const body = `'use strict';
      const __allowed = Object.freeze({ ${keys.map(k => `${k}: ${k}`).join(', ')} });
      return (function() { with(__allowed) { return (${expr}); }})();`;
      // Note: with is disallowed in strict mode; this is illustrative only
    }

    Better: implement an AST interpreter (e.g., jsep for parsing) and evaluate only supported nodes. If you expect structured objects from eval, design them with exact properties and validate using techniques from Typing Objects with Exact Properties in TypeScript.

    8) Preventing spread of any and type assertions

    A common anti-pattern is to immediately cast eval results using as T or force any to satisfy the compiler. Resist that. Instead, convert unknown -> validated type with explicit checks. Example anti-pattern:

    ts
    const raw = safeEval<any>('userInput');
    const user = raw as { name: string }; // unsafe

    Instead do:

    ts
    if (res.ok && isUser(res.value)) {
      // safe to access properties
    }

    For security implications and why broad assertions are dangerous, read Security Implications of Using any and Type Assertions in TypeScript.

    9) Testing, monitoring, and runtime validation

    Unit test every expression scenario you support. Write property-based or fuzz tests for user-provided expressions. Log and instrument evaluation failures for faster debugging. As part of CI, run lints and static checks and include runtime validators (Zod, io-ts) for shape validation. If you are evaluating expressions in JS files, consider adding JSDoc or type checking for developer-supplied scripts to get earlier feedback; see Using JSDoc for Type Checking JavaScript Files (@typedef, @type, etc.).

    Sample test approach using Jest:

    ts
    test('evaluates safe expression', () => {
      const r = safeEval<number>('x + 1', { x: 3 });
      expect(r.ok).toBe(true);
      if (r.ok) expect(r.value).toBe(4);
    });

    Run negative tests for injection attempts and unexpected shapes.

    10) Alternatives to eval: interpreters and expression languages

    Where feasible, replace eval with a small expression language (mathjs, expr-eval) or compile user expressions to a safe intermediate representation that you control. This gives you full typing control over inputs and outputs and avoids arbitrary code execution entirely. When designing the API surface, keep function signatures strict and predictable, mirroring techniques used in other typed libraries.

    If your system must accept dynamic functions but also wants good DX, consider method-chaining or builder-style APIs where you can type each chain step—patterns are similar to those in Typing Libraries That Use Method Chaining in TypeScript.

    Advanced Techniques

    When you need expert-level control, combine static and runtime approaches: generate type contracts from a schema, use code generation to derive TypeScript types for allowed expression ASTs, and use compile-time checked templates for user code. Another advanced approach is to compile a restricted subset of expressions to WebAssembly or to a separate worker process with limited privileges.

    Use schema-first validation (Zod, io-ts, or JSON Schema) to produce both runtime validators and TypeScript types, which reduces duplication and ensures that what you validate at runtime is reflected in compile-time types. Consider generating small deterministic interpreters from your schema so you can type-check expressions as they are constructed.

    On performance: avoid parsing/executing for every evaluation in hot paths; cache parsed ASTs or precompile allowed expressions. Measure and benchmark different strategies — the overhead of validation might be necessary but usually acceptable compared to the cost of unsafe errors.

    Best Practices & Common Pitfalls

    Dos:

    • Do isolate eval usage behind a single API surface.
    • Do treat eval output as unknown until validated with type guards or schemas.
    • Do prefer AST-based interpreters or restricted expression evaluators when possible.
    • Do write comprehensive tests, including fuzz tests for malicious inputs.
    • Do log evaluation failures and enforce strict access controls around who can submit expressions.

    Don'ts:

    • Don’t use as T or any to skip validation; this defeats TypeScript safety.
    • Don’t expose the global context or Node internals to evaluated code.
    • Don’t assume expressions are free from side effects; restrict or disallow side-effectful primitives.
    • Don’t ignore performance implications of complex validations in hot paths.

    Troubleshooting tips:

    • If evaluations throw unexpectedly, add structured logging with the expression, context snapshot, and stack trace.
    • For intermittent failures, add input sampling and additional test coverage to reproduce edge cases.
    • Use type predicates to narrow down failing cases and add defensive checks around property access.

    Real-World Applications

    • Spreadsheet or formula engines where end users author expressions. Replace eval with an expression evaluator that maps to typed results.
    • Templating engines that allow limited expressions in string templates; restrict operations and validate outputs against expected types.
    • Plugin systems for apps where plugins are constrained by API and must be sandboxed; prefer AST-based interpreters and clearly typed plugin interfaces.
    • Legacy systems migrating from dynamic config scripts—use typed configuration patterns and validate script outputs before wiring them into the app state. If you need to type complex configurations, review Typing Configuration Objects in TypeScript: Interfaces vs Type Aliases — An Intermediate Guide.

    Conclusion & Next Steps

    eval is powerful but dangerous. When unavoidable, isolate it, type the wrapper API clearly, and validate every output at runtime. Prefer safer alternatives and invest in testing and instrumentation. Next steps: implement a safeEval wrapper in your codebase, add runtime validators, and replace eval use-cases with interpreters where possible.

    If you want to go deeper, examine type narrowing with custom type predicates and integrating validators with your TypeScript types.

    Enhanced FAQ

    Q: Should I ever cast eval results with as T? A: Avoid casting without validation. as T tells the compiler to trust you, but it does not protect runtime. Use type guards or schema validators to narrow unknown to T safely.

    Q: Is using the Function constructor safer than eval? A: Function isolates scope from local variables, which is slightly safer but still executes arbitrary code. It doesn't protect against malicious operations inside the evaluated code. Always validate and sandbox as much as possible.

    Q: How do I validate complex objects returned by eval? A: Use schema validators like Zod, io-ts, or Ajv. Build schemas that reflect the expected shape and run validation after evaluation. You can then cast to the validated type confidently.

    Q: Can I run eval in a Web Worker or child process to improve safety? A: Running eval in a separate process or worker isolates impact but doesn't eliminate risks. It reduces blast radius—if the evaluated code is malicious, it can't access main process memory—but you still need to guard communication channels and validate data before merging it back.

    Q: What about performance overhead of validation? A: Validation does add overhead. Cache parsed artifacts (ASTs), precompile validators, and avoid revalidating unchanged inputs. Measure and profile to find acceptable trade-offs.

    Q: How to handle expressions that return different types depending on context? A: Model them with unions and discriminators. Use runtime discriminators or normalize outputs into a known envelope shape so you can pattern-match safely.

    Q: Are there safer libraries that replicate eval use-cases? A: Yes. For math and simple expressions consider expr-eval or mathjs. For limited templating, use dedicated template engines. For user-authored logic, consider DSLs compiled to secure runtimes.

    Q: How do I test eval code paths thoroughly? A: Use unit tests for expected happy paths, property-based tests or fuzzers for input variations, negative tests for malicious inputs, and integration tests for context interactions. Instrument logging to capture failing expressions during staging.

    Q: How can TypeScript help even when using eval? A: TypeScript helps by typing the wrapper API, the expected validated outputs, and the surrounding code that consumes eval results. It cannot infer code executed by eval, but it can ensure that once you validate, the rest of your code treats the value safely. For advanced type patterns, consult guides like Typing Functions with Context (the this Type) in TypeScript or Typing Functions with Variable Number of Arguments (Rest Parameters Revisited) to design robust wrapper signatures.

    Q: Should I document evaluated expressions for users? A: Yes. Provide clear docs on the allowed language, available variables, and the shapes expected by the system. If you allow literal tokens or enums, consider using Using as const for Literal Type Inference in TypeScript to represent allowed literals in your types and validators.

    Q: Any suggestions for migrating away from eval in a large codebase? A: Start by isolating eval into a single module and replacing internal call sites with the typed wrapper. Introduce validators and tests. Then incrementally replace eval usage with interpreters or typed configuration fields. For state-heavy systems, see our practical approach in Practical Case Study: Typing a State Management Module.


    Further reading and next topics: learn how to write robust type guards, manage ambiguous return types, and type asynchronous API boundaries — resources include our guides on Using Type Predicates for Custom Type Guards, Typing Functions with Multiple Return Types (Union Types Revisited), and Typing Promises That Resolve with Different Types.

    If you found this guide useful, try applying the safeEval wrapper in a small project and replace one eval use-case with a restricted interpreter. Track errors and iterate until you achieve the desired safety and developer experience.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:20:02 PM
    Next sync: 60s
    Loading CodeFixesHub...