QueueExplorer was originally built as a WinForms application. For its cross-platform version, we replaced the WinForms frontend with Electron while keeping the existing .NET backend. This made TypeScript the main language for the new user interface. TypeScript looked familiar enough to C# that we could just jump in, but there are some important conceptual differences.
The central one is that TypeScript adds only compile-time checks around JavaScript; its types do not exist at runtime. This affects casting, deserialization, structural typing, asynchronous code, and communication with .NET.
Type Assertions Are Not Casts
In C#, a cast is a runtime operation. If an object cannot be cast to the requested type, the failure occurs immediately, usually as an InvalidCastException.
TypeScript has no direct equivalent because its types are removed during compilation.
const message = value as Message;
This performs no conversion and no validation. It only instructs the compiler to treat value as a Message.
If the object happens to contain the properties used immediately afterwards, the code may continue to work. The problem can appear much later, when another method accesses a missing property or expects behavior the object does not have.
The error therefore occurs where the invalid assumption is finally exposed, not where the assertion was made.
This is particularly relevant when parsing JSON:
const message = JSON.parse(json) as Message;
The parsed value is still a plain JavaScript object. The constructor did not run, the prototype was not assigned, and the Message methods are not present.
Structural Typing
C# generally uses nominal typing. Two classes remain different types even if they expose identical members, unless they share an explicit base class or interface.
TypeScript primarily compares structure.
interface NamedItem {
name: string;
}
class QueueInfo {
constructor(
public name: string,
public messageCount: number
) {}
}
function displayName(item: NamedItem): void {
console.log(item.name);
}
displayName(new QueueInfo("orders", 120));
QueueInfo does not need to declare that it implements NamedItem. Its shape is enough.
This is useful in frontend code. The same object can satisfy several narrow interfaces without requiring wrapper classes or formal inheritance relationships.
A larger object can, for example, be viewed differently by components responsible for display, selection, editing, persistence, or drag and drop.
The downside is that unrelated concepts can become compatible by accident:
interface CustomerReference {
id: string;
}
interface MessageReference {
id: string;
}
These interfaces are interchangeable to TypeScript because their structure is identical, even though they represent different domain concepts. It helps to add CustomId and MessageId types in such cases, it’s easy and unobtrusive in TypeScript and you get better compile-time checks.
Async Code Is Easier, but Operations Still Interleave
JavaScript’s asynchronous model works well for GUI applications.
Renderer code runs on a single event loop. This avoids a class of deadlocks familiar from C# desktop applications, especially when synchronous and asynchronous code are mixed and the UI thread blocks while an awaited continuation is trying to resume on that same thread.
JavaScript avoids that specific problem, but await still allows other work to run before the current operation continues.
async function loadSelectedMessage(): Promise<void> {
const selectedId = currentSelection.id;
const message = await backend.loadMessage(selectedId);
messageEditor.show(message);
}
While the backend request is pending, the user may select another message. When the first operation resumes, it can apply an outdated result to a UI that now represents a different selection.
There is no traditional multithreaded race, but the logical operation context has changed. It might come as a surprise as you think of TS/JS as “oh, it’s a simple single-threaded model, nothing can change while my function runs”. It’s not like that.
The usual solutions are cancellation, operation IDs, or validating that the original context is still current before applying the result. And always gather values from UI elements and prepare all required parameters before the first async call.
JavaScript’s single-threaded model removes some GUI concurrency problems, but not the need to reason about interleaving.
Serialization Makes the Differences Concrete
The boundary between .NET and Electron exposes the differences between the two type systems directly.
A C# object cannot cross into TypeScript as the same runtime object. It must be serialized, transferred, parsed, and represented as JavaScript data.
Enums are a simple example.
public enum MessageState
{
Ready,
Processing,
Failed
}
Serializing this as a number produces compact JSON, but a value such as 2 is hard to read and coupled to the enum ordering.
We serialize enums as strings using Newtonsoft.Json’s StringEnumConverter.
{
"state": "Failed"
}
The corresponding TypeScript representation can be a string enum:
enum MessageState {
Ready = "Ready",
Processing = "Processing",
Failed = "Failed"
}
or a union:
type MessageState = "Ready" | "Processing" | "Failed";
String values are easier to inspect in logs and less sensitive to changes in enum ordering.
They still provide no automatic runtime validation on the TypeScript side. A property declared as MessageState can still receive an unexpected string unless the application validates it.
Other values also require some consideration or deliberate mapping:
- dates and TimeStamps;
- byte arrays;
- dictionaries with complex content;
- polymorphic objects.
Each compiler checks its own model. Neither compiler proves that the C# and TypeScript contracts remain compatible. Since byte arrays are serialized as Base64 strings, this affects performance both directly and through memory pressure, as large amounts of memory may need to be allocated and released. If you need to transfer larger byte arrays, consider MessagePack or a similar format, but do not expect it to work as seamlessly as JSON serialization.
TypeScript Tooling in 2026
The language differences discussed above are unlikely to change significantly, but the tooling experience may improve over time. The following reflects our experience with Visual Studio, VS Code and TypeScript development in 2025-26.
As the codebase grew, we found that a successful TypeScript compilation did not provide the same confidence as a successful C# build. Several categories of checks that C# developers are accustomed to receiving from the compiler and IDE require ESLint as an additional analysis layer.
Visual Studio can run both TypeScript and ESLint checks while code is being edited, but the integration is not always consistent. The versions bundled with Visual Studio may behave differently from those installed through npm, or may not match the versions used by the project build. Or just check open files instead of the entire project. After renaming a property or method, errors in unopened files can remain undiscovered until tsc is run manually.
Diagnostics can also be misleading. Visual Studio sometimes reports phantom errors while the actual problem lies elsewhere, making it harder to distinguish a genuine code issue from stale or incomplete analysis.
Running tsc and ESLint through npm provides a more reliable project-wide check, but the results are not integrated as cleanly into Visual Studio as C# compiler diagnostics. ESLint errors produced during an npm build may not appear in the standard Visual Studio error list, so the build output has to be inspected directly.
The overall experience is less cohesive than C# development in Visual Studio. We learned to rely on explicit npm checks rather than assuming that a clean editor and normal Visual Studio build meant the entire project was valid. It’s a bit better in VS Code, but still not at the level we’re used to with .Net.
Looking Back
The difficult part of learning TypeScript was not its syntax. It was adjusting the expectations carried over from C#.
A type assertion is not a runtime cast. An object typed as a class may still be a plain object. Interfaces describe compatible shapes rather than explicit runtime relationships. JavaScript’s single-threaded event loop avoids some GUI deadlocks, but asynchronous operations can still interleave and invalidate their original context.
At the same time, structural typing, union types, and the event-loop model proved useful for frontend development.
You do have to be aware that, after all, it’s still just JavaScript with its numerous quirks. TypeScript on top helps, of course, but don’t think of it as C# with different syntax.