TypeScript Interop in Blazor
Although Blazor makes it possible to develop single-page applications in C# instead of JavaScript or TypeScript, there are still cases where you need to call JavaScript code to accomplish something, such as calling browser APIs or interacting with existing JavaScript libraries. This is called JavaScript interoperability and is well documented. However, it does not mention how you can use Typescript instead of JavaScript.
To learn how to do this, I tried calling the browser's geolocation API. I did this first in JavaScript and later switched to TypeScript.
There are several ways to call JavaScript from C# code. The latest and easiest is to use the JSImport
attribute on a partial method. Unfortunately, this method can not be used in my case because it does not support marshaling custom types as function return values. This way I could not return the whole GeolocationPosition
object as I wanted, so I used the old imperative approach for calling a function in a module:
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Pages/Geolocation.razor.js");
geolocation = await module.InvokeAsync<GeolocationPosition>(
"getCurrentPosition", null);
Of course, I had to define the C# equivalent of GeolocationPosition
to use as a type in the C# type of the code:
public class GeolocationPosition
{
public GeolocationCoordinates Coords { get; set; } = new();
public long Timestamp { get; set; }
}
public class GeolocationCoordinates
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public double? Altitude { get; set; }
public double Accuracy { get; set; }
public double? AltitudeAccuracy { get; set; }
public double? Heading { get; set; }
public double? Speed { get; set; }
}
I placed the JavaScript code in a JavaScript file collocated with the component that uses it:
export async function getCurrentPosition(options) {
return await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options);
});
}
I needed to return a Promise
so that it would be converted to a Task
that could be called asynchronously by C# code.
Unfortunately, this first attempt failed miserably: all properties in the resulting C# class were set to their default values. However, debugging showed that the API had been called successfully in JavaScript and had returned the expected values.
After some investigation, I found that this was because the values are marshalled between the JavaScript and C# contexts using JSON serialization. Serializing GeolocationPosition
to JSON results in an empty object because all of its properties are part of the prototype and they are not included in the serialization. If you create a new JavaScript object and map all the properties of GeolocationPosition
to it, the problem is fixed:
export async function getCurrentPosition(options) {
let position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options);
});
let mappedPosition = {
coords: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
accuracy: position.coords.accuracy,
altitudeAccuracy: position.coords.altitudeAccuracy,
heading: position.coords.heading,
speed: position.coords.speed,
},
timestamp: position.timestamp,
};
return mappedPosition;
}
At this point, my example was working, so it was time to convert the JavaScript code to TypeScript:
export async function getCurrentPosition(
options?: PositionOptions
): Promise<GeolocationPosition> {
let position = await new Promise(
(resolve: PositionCallback, reject: PositionErrorCallback) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options);
}
);
let mappedPosition: GeolocationPosition = {
coords: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
accuracy: position.coords.accuracy,
altitudeAccuracy: position.coords.altitudeAccuracy,
heading: position.coords.heading,
speed: position.coords.speed,
},
timestamp: position.timestamp,
};
return mappedPosition;
}
In order to call this code from C#, it had to be transpiled to JavaScript when the Blazor project was built. To accomplish this, several steps were required:
- install the Microsoft.TypeScript.MSBuild NuGet package
create a
tsconfig.json
file in the root folder of the Blazor project (the following command line allows you to do this without installing the TypeScript NPM package globally):npx -p typescript tsc --init
instruct TypeScript to generate the ES6 module code required by Blazor by changing the value of the module property accordingly, resulting in the following content of the
tsconfig.json
file:{ "compilerOptions": { "target": "es2016", "module": "ES6", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true } }
exclude the generated JavaScript files from Git by adding them to
.gitignore
:*.razor.js
The full source code for this example can be found in my GitHub repository. The last commit uses TypeScript and the previous one uses JavaScript.
The approach from this blog post can be used for calling any kind of JavaScript (or TypeScript) code from C# code in Blazor. You should think of it as a general guide, not the recommended way to call the browser Geolocation API. If that's all you need to do, you are better off installing one of the available production-ready NuGet packages instead.