CodeFixesHub
    programming tutorial

    Calling JavaScript from TypeScript and Vice Versa: A Practical Guide

    Master calling JavaScript from TypeScript and vice versa with examples, declarations, and troubleshooting. Learn best practices—read the full guide now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 25
    Published
    19
    Min Read
    2K
    Words
    article summary

    Master calling JavaScript from TypeScript and vice versa with examples, declarations, and troubleshooting. Learn best practices—read the full guide now.

    Calling JavaScript from TypeScript and Vice Versa: A Practical Guide

    Introduction

    Working across TypeScript and JavaScript boundaries is a common requirement in modern codebases. Whether you're progressively migrating a legacy JS codebase to TypeScript, publishing a library for consumption by both TS and JS users, or simply consuming an untyped third-party script, you need robust, maintainable patterns for interop. This guide teaches intermediate developers how to call JavaScript from TypeScript and call TypeScript from JavaScript while maintaining type safety, developer ergonomics, and build reliability.

    You'll learn how to consume untyped modules safely, author declaration files (.d.ts), use DefinitelyTyped and @types packages, configure tsconfig.json for mixed projects, and troubleshoot missing or incorrect types. We'll cover practical examples with Node and browser builds, how module systems (CommonJS, ESM, UMD) affect interop, and when to prefer ambient declarations vs. authored declaration files.

    By the end, you will be able to: safely import and call plain JS from TypeScript, export TypeScript types for JS consumers, debug missing type errors, and apply best practices to avoid runtime surprises. Along the way, you'll find links to related in-depth resources like how to write declaration files, tsconfig configuration, and handling ambient/global declarations.

    Background & Context

    TypeScript is a superset of JavaScript that adds static typing. That means TypeScript can consume JavaScript with varying amounts of type information — from fully typed modules to completely untyped scripts. Interop becomes a balancing act between developer productivity and type safety. When a module lacks type declarations, TypeScript will treat it as any (or emit an error under strict settings). Properly exposing types (either via shipped .d.ts files or via DefinitelyTyped) unlocks the full TypeScript experience for consumers.

    Understanding how the compiler resolves types, and how bundlers and runtime module systems load code, is essential. Good interop reduces bugs, improves IDE autocompletion, and makes library consumption predictable. If you're uncertain about tsconfig options, start with a clear configuration; see our practical guide to Introduction to tsconfig.json: Configuring Your Project for setup patterns that work for mixed projects.

    Key Takeaways

    • How to import and call plain JavaScript from TypeScript safely.
    • When and how to write or ship .d.ts declaration files.
    • How to use @types/DefinitelyTyped and troubleshoot missing types.
    • Compiler flags and tsconfig patterns for mixed-language projects.
    • Differences in interop behavior across CommonJS, ESM, and UMD.
    • Best practices for publishing libraries usable from both TS and JS.

    Prerequisites & Setup

    Before you begin, ensure you have a recent Node.js LTS and npm/yarn installed, plus TypeScript installed as a dev dependency (tsc). Basic familiarity with ES modules and CommonJS will help. For IDE experience, use VS Code or another editor with TypeScript support. If working with bundlers, have a minimal bundler config (esbuild, Rollup, or Webpack) ready. Consider enabling type-checking options in tsconfig.json; if you need a primer, see Setting Basic Compiler Options: rootDir, outDir, target, module.

    Create a sample project:

    javascript
    mkdir ts-js-interop && cd ts-js-interop
    npm init -y
    npm install --save-dev typescript
    npx tsc --init

    Tweak your tsconfig.json according to your module system and build pipeline.

    Main Tutorial Sections

    Why Interop Matters and Common Scenarios

    Interop is necessary when you:

    • Migrate a large JS codebase incrementally to TypeScript.
    • Consume NPM packages that don’t ship types.
    • Publish a library that must support both JS and TS consumers.
    • Mix third-party scripts or legacy globals into a modern app.

    Common pain points include missing or wrong .d.ts files, ambient globals that collide, and runtime shape mismatches. When declarations are missing, consult Troubleshooting Missing or Incorrect Declaration Files in TypeScript for targeted fixes.

    Calling JavaScript from TypeScript: Basic Imports

    If a JS module has no type info, you can import it and assert types or create a local declaration.

    Example: import an untyped CommonJS module in TypeScript:

    ts
    // runtime JS file: lib/logger.js
    // module.exports = function(msg) { console.log(msg); }
    
    // in TypeScript
    import logger = require('./lib/logger');
    logger('hello from TS');

    To avoid 'any' creeping in, add a minimal declaration in a global declarations file, or write a module d.ts (see "Using Declaration Files" below). You can also use 'as' to assert a narrower type: const logger = require('./lib/logger') as (msg: string) => void.

    Consuming Untyped JS Modules: Strategies

    You have four main strategies:

    1. Add a quick declare module in a local d.ts to document the shape.
    2. Install an @types package from DefinitelyTyped if available.
    3. Author and ship a proper declaration file for your project.
    4. Use JSDoc with // @ts-check to provide types inline for JS files.

    For third-party packages, check Using DefinitelyTyped for External Library Declarations.

    Example local shim:

    ts
    // types/shims.d.ts
    declare module 'old-untyped' {
      export function doThing(x: string): number;
    }

    Place this file in a folder included by tsconfig.json (or add "typeRoots").

    Using Declaration Files (.d.ts): Writing and Consuming

    Declaration files describe the public surface of JS modules. A small .d.ts can make an untyped module friendly to TypeScript users. For an introduction, see Introduction to Declaration Files (.d.ts): Typing Existing JS and our hands-on guide to Writing a Simple Declaration File for a JS Module.

    Example .d.ts for a simple default export:

    ts
    // index.d.ts
    declare module 'greeter' {
      export default function greeter(name: string): string;
    }

    For global libraries that attach to window or globalThis, refer to Declaration Files for Global Variables and Functions.

    Publishing TypeScript Types with Your Package

    If you author a library in TypeScript, enable declaration generation in tsconfig:

    json
    {
      "compilerOptions": {
        "declaration": true,
        "declarationMap": true,
        "outDir": "dist",
        "module": "esnext"
      }
    }

    Set the package.json "types" field to point at the generated d.ts (for example "types": "dist/index.d.ts"). This allows JS consumers to still get type hints if they use TypeScript-aware tooling. If you ship only JS, consider publishing types to DefinitelyTyped. See advice on generating and troubleshooting declarations in Troubleshooting Missing or Incorrect Declaration Files in TypeScript.

    Calling TypeScript from Plain JavaScript

    TypeScript can emit plain JavaScript that any JS runtime can consume. Export shapes that are stable across builds. If you want JS users to get type definitions, ship .d.ts alongside the JS as shown above. Example: export a class in TypeScript and consume from JS:

    ts
    // src/api.ts
    export class ApiClient {
      constructor(private url: string) {}
      request() { /* ... */ }
    }

    After compilation, JS user can:

    js
    const { ApiClient } = require('./dist/api');
    const c = new ApiClient('https://example');

    Add types by distributing the generated d.ts or publishing to DefinitelyTyped.

    Module Systems, Bundlers, and Interop Caveats

    CommonJS vs ESM differences cause confusion. For CommonJS default exports, TypeScript's esModuleInterop and allowSyntheticDefaultImports control import syntax. Example configuration:

    json
    {"compilerOptions": { "esModuleInterop": true, "module": "commonjs" }}

    If a package uses UMD or attaches to window, you might need declare global patterns (see global declarations). When bundling, ensure your bundler preserves the runtime module semantics you expect. For project-level build settings, our guide on Setting Basic Compiler Options: rootDir, outDir, target, module can help you align TypeScript's output with your bundler.

    Handling Globals and Ambient Declarations

    When a script exposes globals (for example, a third-party analytics script), create an ambient declaration:

    ts
    // globals.d.ts
    declare global {
      interface Window { analytics: { track: (e: string) => void } }
    }
    export {}

    Reference directives (/// <reference path=... />) are sometimes used to bring in ambient files in legacy setups. Learn when to use reference directives safely in Understanding /// Directives in TypeScript.

    Troubleshooting Missing Types and Quick Fixes

    When you see Could not find a declaration file for module 'foo', try:

    • Search for @types/foo on npm.
    • Add a local shim declare module 'foo' to avoid build breaks.
    • Create or generate a proper .d.ts and include it in your package.

    A systematic troubleshooting approach is outlined in Troubleshooting Missing or Incorrect Declaration Files in TypeScript.

    Advanced Techniques

    Once you are comfortable with the basics, you can apply advanced patterns to make interop safer and more ergonomic. Use conditional types and infer in complex declaration files to express return-value relationships between dynamic functions — for example mapping string-keyed APIs into typed wrappers; a refresher on advanced conditional types helps, see Using infer in Conditional Types: Inferring Type Variables. Key advanced strategies:

    • Declaration merging to extend third-party typings without forking.
    • Using declaration maps (declarationMap: true) for better debugging when types are consumed across packages.
    • Publishing high-quality types to DefinitelyTyped to help the community; see Using DefinitelyTyped for External Library Declarations.

    Performance tip: keep your public surface small and avoid large recursive types in shipped declarations — heavy types can slow IDEs and the compiler.

    Best Practices & Common Pitfalls

    Dos:

    Don'ts:

    • Don’t rely on unchecked any types in public APIs.
    • Avoid merging ambient declarations in global scope unless necessary.
    • Don’t emit incompatible module formats without documenting runtime expectations.

    Common Pitfalls:

    • Broken runtime due to incorrect default vs named export assumptions.
    • Missing types in CI because local typeRoots are not included.

    For broader compiler strictness recommendations that prevent subtle runtime issues, read Understanding strict Mode and Recommended Strictness Flags.

    Real-World Applications

    Interop patterns apply to many scenarios:

    • Migrating a legacy Node.js app incrementally to TypeScript while keeping runtime stable.
    • Creating a front-end widget distributed as a UMD bundle that must run in plain JS environments with optional TypeScript types.
    • Publishing libraries: shipping JS output with .d.ts files to provide types to consumers, or contributing types to DefinitelyTyped for wider reach.

    An example use case: a company's analytics package written in JS can be incrementally typed by adding declaration files and enabling noImplicitAny selectively in a migration branch. For help with declaration quality and versioning, consult Declaration Files for Global Variables and Functions.

    Conclusion & Next Steps

    Interop between TypeScript and JavaScript is a practical skill that improves maintainability and developer experience. Start by adding small declaration shims for third-party modules, enable useful compiler checks, and gradually tighten your types. Next steps: write a few .d.ts files for key modules, publish or contribute types to DefinitelyTyped if relevant, and standardize tsconfig settings across your mono-repo.

    Recommended reading: our guides on writing declaration files, troubleshooting missing declarations, and configuring tsconfig all provide deeper context for the patterns in this article: Writing a Simple Declaration File for a JS Module, Troubleshooting Missing or Incorrect Declaration Files in TypeScript, and Introduction to tsconfig.json: Configuring Your Project.

    Enhanced FAQ

    Q: What’s the quickest way to silence "Could not find a declaration file for module 'x'"?
    A: Add a minimal shim: create a file like types/shims.d.ts with declare module 'x'; and include that path in your tsconfig include or typeRoots. This avoids runtime changes and gives you time to author proper types.

    Q: Should I always publish .d.ts files with my package?
    A: Yes, if you want TypeScript consumers to have IDE hints and type safety. If your package is authored in TypeScript, set declaration: true and point package.json "types" to the generated file. If you cannot include types, consider contributing to DefinitelyTyped instead; see Using DefinitelyTyped for External Library Declarations.

    Q: When do I use declare global vs declare module?
    A: Use declare module 'x' {} for packages you import. Use declare global when a script attaches to window or globalThis and you need to expose ambient globals across your codebase. For patterns and pitfalls with globals, see Declaration Files for Global Variables and Functions.

    Q: How do I handle default vs named export mismatches between CommonJS and ESM?
    A: Set esModuleInterop: true and allowSyntheticDefaultImports: true for smoother default import behavior. Alternatively, use the import = require(...) syntax for explicit CommonJS interop. Be consistent across your project and document the expected import form.

    Q: My .d.ts compiles but types are wrong at runtime. How to debug?
    A: Types describe shape, not behavior. Ensure the runtime module actually returns the shape your types declare. Use small smoke tests that import and call the module at runtime and validate expectations. If you generate declarations from TS, enable declarationMap so consumers can trace types back to sources.

    Q: How can I provide types for a library I don't control?
    A: Publish types to DefinitelyTyped by opening a pull request to the repository, or ship a small @types/your-lib package. Quick local fixes include declare module shims.

    Q: Are JSDoc and // @ts-check viable alternatives to .d.ts files?
    A: For JS-first codebases, JSDoc with // @ts-check provides inline type hints without separate .d.ts files and is a great incremental strategy. For library distribution, .d.ts is preferred for consumers using TypeScript.

    Q: How do I avoid inadvertent any creeping into my APIs?
    A: Turn on strict checks and noImplicitAny and fix issues proactively. Learn approaches to find and fix untyped code in Using noImplicitAny to Avoid Untyped Variables.

    Q: Can advanced TypeScript features help me create safer declarations?
    A: Yes. Conditional types, mapped types, and infer let you express complex relationships in your public API surface. For guidance on using infer in conditional types, see Using infer in Conditional Types: Inferring Type Variables. Use them sparingly to avoid extravagant types that hurt IDE performance.

    Q: When should I use reference directives /// <reference path=... />?
    A: Prefer module-based imports and typeRoots. Use /// <reference> only when you must explicitly control build ordering for legacy scripts. For more on safe use of reference directives, read Understanding /// Directives in TypeScript.

    Q: My editor can't find my local declarations — what did I miss?
    A: Ensure the declaration files are in included paths (tsconfig include or typeRoots) and not excluded by exclude rules. Also confirm allowJs or checkJs if mixing JS and TS files. If problems persist, run npx tsc --noEmit to get the compiler's diagnostics and trace the issue.

    Q: Is it better to provide a narrow typed wrapper around an untyped dependency?
    A: Often yes. A small typed wrapper isolates the untyped surface and gives you place to assert or validate inputs/outputs. This reduces the blast radius of any and makes future migrations clearer.

    Q: How do I decide between shipping types with my package vs contributing to DefinitelyTyped?
    A: If you control the package, ship types with releases to ensure version alignment. If you don't control the package, contribute to DefinitelyTyped. For writing new open-source packages, include types by default.


    If you'd like, I can generate a starter types/shims.d.ts and a sample tsconfig.json tuned for your project (Node/Browser, CommonJS/ESM), or walk through authoring a concrete .d.ts for a real module you specify.

    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:19:53 PM
    Next sync: 60s
    Loading CodeFixesHub...