Implementing JavaScript and TypeScript Interoperability in Blazor WebAssembly

Setting Up the Project

Create a new Blazor WebAssembly App and name it BlazorTSInterop. Select .NET 6.0 client-only with no authentication and no PWA support. Run the application in hot reload mode with CTRL+F5.

The template includes counter and fetch data pages that won't be needed for this demonstration. We'll work exclusively with the home page.

JavaScript Interoperability Fundamentals

1. Invoking Browser JavaScript APIs

Replace the entire contents of Pages/Index.razor with the following code:

@page "/"
@inject IJSRuntime JSRuntime

<h1>Hello, Interop!</h1>
<hr />
@StatusMessage
<hr />
<h4>JS Interop</h4>
<button class="btn btn-primary" @onclick="ShowPrompt">Prompt</button>
<hr>

@code{
    private string StatusMessage = string.Empty;

    private async Task ShowPrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("prompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
    }
}

Explanation:

The InvokeAsync method accepts the following parameters:

  • First parameter: The name of the JavaScript function to execute
  • Subsequent parameters: Arguments to pass to the JavaScript function
  • Return value: A generic type representing the function's return type

Save and test in hot reload mode.

2. Calling Embedded JavaScript

Create a new js folder under wwwroot for JavaScript and TypeScript files. Add a new file at wwwroot/js/utilities.js:

function ShowScriptPrompt(msg) {
    return prompt(msg);
}

function ShowScriptAlert(msg) {
    alert(msg);
}

These functions call the browser's prompt and alert APIs. They're globally accessible to other JavaScript modules including isolated ones.

Reference the script in wwwroot/index.html after the webassembly framework:

<body>
    ...
    <script src="_framework/blazor.webassembly.js"></script>
    <script src="js/utilities.js"></script>
    ...
</body>

Update Pages/Index.razor with buttons for each function:

@page "/"
@inject IJSRuntime JSRuntime

<h1>Hello, Interop!</h1><br />
<h4 style="background-color:aliceblue; padding:20px">JavaScript Interop</h4>
@StatusMessage<hr />

<button class="btn btn-primary" @onclick="NativePrompt">Prompt</button>
<button class="btn btn-primary" @onclick="ScriptPrompt">Script Prompt</button>
<button class="btn btn-primary" @onclick="ScriptAlert">Script Alert</button>
<hr>

@code{
    private string StatusMessage = string.Empty;

    private async Task NativePrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("prompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task ScriptPrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("ShowScriptPrompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task ScriptAlert()
    {
        await JSRuntime.InvokeVoidAsync("ShowScriptAlert", "Script Alert");
    }
}

Run and test all buttons.

3. Working with Isolated JavaScript Modules

Create a new JavaScript module file at wwwroot/js/module-utils.js:

import { ShowScriptPrompt, ShowScriptAlert } from './utilities.js';

export function ModulePrompt(message) {
    return ShowScriptPrompt(message);
}

export function ModuleAlert(message) {
    return ShowScriptAlert(message);
}

The export keyword marks these functions as importable using ES module syntax. The global utilities.js doesn't use export.

Update Index.razor to include module functionality:

@page "/"
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable

<h1>Hello, Interop!</h1><br />
<h4 style="background-color:aliceblue; padding:20px">JavaScript Interop</h4>
@StatusMessage<hr />

<button class="btn btn-primary" @onclick="NativePrompt">Prompt</button>
<button class="btn btn-primary" @onclick="ScriptPrompt">Script Prompt</button>
<button class="btn btn-primary" @onclick="ScriptAlert">Script Alert</button>
<hr>

<button class="btn btn-primary" @onclick="ModulePrompt">Module Prompt</button>
<button class="btn btn-primary" @onclick="ModuleAlert">Module Alert</button>
<hr>

@code{
    private IJSObjectReference utilitiesModule;
    private string StatusMessage = string.Empty;
    private string CacheBuster = $"?v={DateTime.Now.Ticks}";

    private async ValueTask DisposeAsync()
    {
        if (utilitiesModule is not null)
            await utilitiesModule.DisposeAsync();
    }

    protected override async Task OnAfterRenderAsync(bool initialRender)
    {
        if (initialRender)
        {
            utilitiesModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
                "import", $"./js/module-utils.js{CacheBuster}");
        }
    }

    private async Task ModuleAlert()
    {
        await utilitiesModule.InvokeVoidAsync("ModuleAlert", "Module Alert");
    }

    private async Task ModulePrompt()
    {
        string result = await utilitiesModule.InvokeAsync<string>("ModulePrompt", "Module Prompt - say what?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task NativePrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("prompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task ScriptPrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("ShowScriptPrompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task ScriptAlert()
    {
        await JSRuntime.InvokeVoidAsync("ShowScriptAlert", "Script Alert");
    }
}

Key implementation details:

  • Isolated modules implement IAsyncDisposable to clean up resources when no longer needed
  • The module loads via OnAfterRenderAsync after the first render
  • ModulePrompt demonstrates calling static methods from utilities.js
  • ModuleAlert demonstrates calling another exported method within the same module

The CacheBuster parameter prevents browser caching during development:

private string CacheBuster { get { return "?v=" + DateTime.Now.Ticks.ToString(); } }
...
utilitiesModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
                    "import", "./js/module-utils.js" + CacheBuster);

For production, replace CacheBuster with the application version number to force a single cache refresh on first run of each new version.

Debugging JavaScript in Visual Studio

Visual Studio sometimes has difficulty attaching to the Chrome debugger, especially as JavaScript code grows.

Troubleshooting steps:

  1. Set breakpoints in utilities.js and run in Debug mode (F5)
  2. If the breakpoint circle appears hollow, the debugger isn't attached
  3. Check the "Script Documents" folder for cached files—click to verify you're viewing the current version
  4. Remove and reapply breakpoints to force debugger reattachment
  5. If still issues, open Chrome DevTools with CTR+Shift+I, select the source file, and set a breakpoint there—this often triggers Visual Studio to reattach

TypeScript Interoperability

1. Calling Isolated TypeScript

Create a new file at scripts/greetings.ts:

declare function ShowScriptAlert(message: string): void;

export class Greetings {
    sayHello(): void {
        ShowScriptAlert("hello");
    }

    static sayGoodbye(): void {
        ShowScriptAlert("goodbye");
    }
}

export var GreetingsInstance = new Greetings();

Note how the class accesses the embedded utilities.js function via the declaration.

Install TypeScript build support by right-clicking the project and selecting Add > New Item. Choose "TypeScript JSON Configuration File."

Update tsconfig.json with compiler options:

{
  "compilerOptions": {
    "noImplicitAny": false,
    "noEmitOnError": true,
    "removeComments": false,
    "sourceMap": true,
    "target": "es2015",
    "outDir": "wwwroot/js"
  },
  "include": ["scripts/**/*"]
}

Update Index.razor to integrate TypeScript:

@page "/"
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable

<h1>Hello, Interop!</h1><br />
<h4 style="background-color:aliceblue; padding:20px">JavaScript Interop</h4>
@StatusMessage<hr />

<button class="btn btn-primary" @onclick="NativePrompt">Prompt</button>
<button class="btn btn-primary" @onclick="ScriptPrompt">Script Prompt</button>
<button class="btn btn-primary" @onclick="ScriptAlert">Script Alert</button>
<hr>

<button class="btn btn-primary" @onclick="ModulePrompt">Module Prompt</button>
<button class="btn btn-primary" @onclick="ModuleAlert">Module Alert</button>
<hr>

<h4 style="background-color:aliceblue; padding:20px">TypeScript Interop</h4><hr>
<button class="btn btn-primary" @onclick="ShowGreetings">Hello Alert</button>

@code{
    private IJSObjectReference utilitiesModule;
    private IJSObjectReference greetingsModule;
    private string StatusMessage = string.Empty;
    private string CacheBuster = $"?v={DateTime.Now.Ticks}";

    private async ValueTask DisposeAsync()
    {
        if (utilitiesModule is not null)
            await utilitiesModule.DisposeAsync();
    }

    protected override async Task OnAfterRenderAsync(bool initialRender)
    {
        if (initialRender)
        {
            utilitiesModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
                "import", $"./js/module-utils.js{CacheBuster}");
            greetingsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
                "import", $"./js/greetings.js{CacheBuster}");
        }
    }

    private async Task ModuleAlert()
    {
        await utilitiesModule.InvokeVoidAsync("ModuleAlert", "Module Alert");
    }

    private async Task ModulePrompt()
    {
        string result = await utilitiesModule.InvokeAsync<string>("ModulePrompt", "Module Prompt - say what?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task NativePrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("prompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task ScriptPrompt()
    {
        string result = await JSRuntime.InvokeAsync<string>("ShowScriptPrompt", "What would you like to say?");
        StatusMessage = $"Prompt: {(string.IsNullOrEmpty(result) ? "nothing" : result)}";
        StateHasChanged();
    }

    private async Task ScriptAlert()
    {
        await JSRuntime.InvokeVoidAsync("ShowScriptAlert", "Script Alert");
    }

    private async Task ShowGreetings()
    {
        await greetingsModule.InvokeVoidAsync("GreetingsInstance.sayHello");
        await greetingsModule.InvokeVoidAsync("Greetings.sayGoodbye");
    }
}

A second module loads the JavaScript generated from greetings.ts. The ShowGreetings method demonstrates calling both static and instance methods from the TypeScript class, which internally use functions from utilities.js.

2. Setting Up Webpack Build Pipeline

Right-click the project folder and select "Open in Terminal." Initialize npm:

npm init -y

Install webpack and TypeScript tools:

npm i ts-loader typescript webpack webpack-cli

Update package.json with build scripts:

{
  "name": "blazortsinterop",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "ts-loader": "^9.3.1",
    "typescript": "^4.8.2",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0"
  }
}

Update tsconfig.json for webpack integratino:

{
  "display": "Node 14",
  "compilerOptions": {
    "allowJs": true,
    "noImplicitAny": false,
    "noEmitOnError": true,
    "removeComments": false,
    "sourceMap": true,
    "lib": ["es2020", "DOM"],
    "target": "es6",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node"
  },
  "include": ["scripts/**/*"],
  "exclude": ["node_modules", "wwwroot"]
}

Create webpack.config.js in the project root:

const path = require("path");

module.exports = {
    mode: 'development',
    devtool: 'eval-source-map',
    module: {
        rules: [
            {
                test: /\.(ts)$/,
                exclude: /node_modules/,
                include: [path.resolve(__dirname, 'scripts')],
                use: 'ts-loader',
            }
        ]
    },
    resolve: {
        extensions: ['.ts', '.js'],
    },
    entry: {
        index: ['./scripts/index']
    },
    output: {
        path: path.resolve(__dirname, './wwwroot/public'),
        filename: '[name]-bundle.js',
        library: "[name]"
    }
};

This configuration tells webpack to use ts-loader to transpile TypeScript files. Each entry creates a JavaScript library accessible via its name.

Update the project file (.csproj) to run webpack during builds:

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
...
  <Target Name="PreBuild" BeforeTargets="PreBuildEvent">
    <Exec Command="npm install" WorkingDirectory="wwwroot" />
    <Exec Command="npm run build" WorkingDirectory="wwwroot" />
  </Target>
...
</Project>

Create scripts/index.ts as a wrapper for the Hello class:

import { Greetings, GreetingsInstance } from "./greetings";
export { Greetings, GreetingsInstance } from "./greetings";

export class Entry {
    sayHello(): void {
        GreetingsInstance.sayHello();
    }

    static sayGoodbye(): void {
        Greetings.sayGoodbye();
    }
}

export var EntryInstance = new Entry();

Build with Ctrl+Shift+B to create index.js and index-bundle.js in the public directory. Verify the bundle includes dependencies by searching for ShowScriptAlert.

Add the bundled script to Index.html:

<body>
    ...
    <script src="_framework/blazor.webassembly.js"></script>
    <script src="js/utilities.js"></script>
    <script src="public/index-bundle.js"></script>
    ...
</body>

Add buttons for bundle interaction in Index.razor:

<button class="btn btn-primary" @onclick="BundleEntryHello">Bundle Entry Hello</button>
<button class="btn btn-primary" @onclick="ReExportGreetings">ReExport Greetings</button>

Add the corresponding methods:

private async Task BundleEntryHello()
{
    await JSRuntime.InvokeVoidAsync("index.EntryInstance.sayHello");
    await JSRuntime.InvokeVoidAsync("index.Entry.sayGoodbye");
}

private async Task ReExportGreetings()
{
    await JSRuntime.InvokeVoidAsync("index.GreetingsInstance.sayHello");
    await JSRuntime.InvokeVoidAsync("index.Greetings.sayGoodbye");
}

The bundle exposes dependencies through exported modules, accessible via the library prefix in interop calls.

3. Integrating NPM TypeScript Packages

Add three.js to the project:

npm i three

Create scripts/rotating-cube.ts:

import * as THREE from 'three';

export class RotatingCube {
    private camera: THREE.PerspectiveCamera;
    private scene: THREE.Scene;
    private renderer: THREE.WebGLRenderer;
    private cube: any;

    constructor() {
        this.camera = new THREE.PerspectiveCamera(75, 2, 0.1, 5);
        this.camera.position.z = 2;
        
        const canvas = document.querySelector('#cube') as HTMLCanvasElement;
        this.renderer = new THREE.WebGLRenderer({ 
            canvas: canvas, 
            alpha: true, 
            antialias: true 
        });
        
        this.scene = new THREE.Scene();
        this.scene.background = null;
        
        const light = new THREE.DirectionalLight(0xFFFFFF, 1);
        light.position.set(-1, 2, 4);
        this.scene.add(light);

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const loadManager = new THREE.LoadingManager();
        const loader = new THREE.TextureLoader(loadManager);
        const texBlazor = loader.load('images/blazor.png');
        const texInterop = loader.load('images/interop.png');
        const texCircle = loader.load('images/tscircle.png');

        const matBlazor = new THREE.MeshPhongMaterial({ 
            color: 0xffffff, 
            map: texBlazor, 
            transparent: false, 
            opacity: 1 
        });
        const matInterop = new THREE.MeshPhongMaterial({ 
            color: 0xffffff, 
            map: texInterop, 
            transparent: false, 
            opacity: 1 
        });
        const matCircle = new THREE.MeshPhongMaterial({ 
            color: 0xffffff, 
            map: texCircle, 
            transparent: false, 
            opacity: 1 
        });
        
        const materials = [matBlazor, matInterop, matCircle, matBlazor, matInterop, matCircle];

        loadManager.onLoad = () => {
            this.cube = new THREE.Mesh(geometry, materials);
            this.scene.add(this.cube);
            this.startAnimation();
        };
    }

    private startAnimation(time = 0): void {
        time = performance.now() * 0.0005;
        this.cube.rotation.x = time;
        this.cube.rotation.y = time;
        this.renderer.render(this.scene, this.camera);
        requestAnimationFrame(this.startAnimation.bind(this));
    }

    static initialize(): void {
        new RotatingCube();
    }
}

Update webpack.config.js entry section:

entry: {
    index: ['./scripts/index'],
    cube: ['./scripts/rotating-cube']
}

Add the bundle to Index.html:

<script src="public/cube-bundle.js"></script>

Add the canvas element to Index.razor:

<canvas id="cube"/>

Update OnAfterRenderAsync to initialize the cube:

protected override async Task OnAfterRenderAsync(bool initialRender)
{
    if (initialRender)
    {
        utilitiesModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
            "import", $"./js/module-utils.js{CacheBuster}");
        greetingsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
            "import", $"./js/greetings.js{CacheBuster}");
        await JSRuntime.InvokeVoidAsync("cube.RotatingCube.initialize");
    }
}

Software Architecture Considerations

TypeScript integration provides significant benefits for interop software architecture. Key design patterns include:

  • TypeScript transpiles to browser-compatible JavaScript for interop readiness
  • Blazor C# compiles to WebAssembly for browser execution
  • C# interfaces should mirror TypeScript counterparts for consistency
  • Blazor WebAssembly communicates with the browser through interoperable JavaScript

This layered approach separates concerns while maintaining type safety and clear boundaries between the client-side UI framework and browser APIs.

Tags: Blazor WebAssembly TypeScript javascript interoperability

Posted on Mon, 11 May 2026 09:03:49 +0000 by eva21