Ensuring Type Safety Between TypeScript and C# Applications

Building a seamless connection between a TypeScript application and a C# application can feel like bridging two worlds. Both languages have strong typing capabilities, but ensuring that they adhere to the same contract can be challenging. In my experience, the best approach is to define a shared schema that governs the data structures and enforce it in both ecosystems. Here, I’ll walk you through how I tackled this problem, leveraging JSON Schema as the common ground and tools like json-schema-to-typescript and NJsonSchema to maintain consistency.

Thank me by sharing on Twitter 🙏

The Problem: Maintaining a Consistent Contract

Imagine you have two applications that need to communicate reliably. Your TypeScript frontend sends data to a C# backend, or vice versa. Without a shared contract, mismatches in the data structure could lead to runtime errors that are frustrating to debug. For example, a missing required field or a type mismatch could cause an application to crash or behave unexpectedly. That’s why having a formal, shared schema is crucial.

By generating types and classes directly from a schema, you eliminate the guesswork and make sure both applications remain in sync.

Step 1: Define the Shared Schema

The first step is creating a JSON Schema that represents the data structure you want to share. JSON Schema is language-agnostic and widely supported, making it perfect for this task. For instance, let’s say I want to define a User object. I created a file named User.schema.json with the following content:

JSON
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "User",
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "name": { "type": "string" },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["id", "name", "email"]
}

This schema specifies that a User object must have an id, a name, and an email. The email field even includes a format validator for added assurance.

Step 2: Generate TypeScript Types

To use the schema in my TypeScript application, I turned to json-schema-to-typescript. This tool converts a JSON Schema into a TypeScript definition file, ensuring type safety on the frontend. Here’s how I did it:

  1. Install the tool: npm install -g json-schema-to-typescript
  2. Run the command to generate a TypeScript file: json-schema-to-typescript –input User.schema.json –output User.ts

The result was a User.ts file containing the following code:

TypeScript
/**
 * User
 */
export interface User {
  id: string;
  name: string;
  email: string;
}

Now, I can confidently use the User type in my TypeScript code. Any deviation from the schema will be caught during development.

Step 3: Generate C# Classes

On the C# side, I used NJsonSchema, a library that supports generating C# classes from JSON Schema. This ensured the backend adhered to the same structure as the frontend. Setting it up was straightforward:

1. Install NJsonSchema (via NuGet):

ShellScript
dotnet add package NJsonSchema.CodeGeneration.CSharp

2. Use the library to generate a C# class:

C#
using NJsonSchema;
using NJsonSchema.CodeGeneration.CSharp;

var schema = await JsonSchema.FromFileAsync("User.schema.json");
var generator = new CSharpGenerator(schema);
var fileContent = generator.GenerateFile();

System.IO.File.WriteAllText("User.cs", fileContent);

This generated a User.cs file with the following code:

C#
using System;
using Newtonsoft.Json;

public class User
{
    [JsonProperty("id")]
    public string Id { get; set; }

    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("email")]
    public string Email { get; set; }
}

The generated C# class mirrors the JSON Schema, ensuring the backend aligns perfectly with the frontend.

Step 4: Validate Data at Runtime

While generating types and classes is a great start, I wanted to add an extra layer of safety by validating data at runtime. For this, I used the following tools:

In TypeScript: I used Zod, a TypeScript-first schema validation library.

TypeScript
import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email()
});

type User = z.infer<typeof UserSchema>;

const user: User = { id: "123", name: "Alice", email: "alice@example.com" };

try {
  UserSchema.parse(user);
} catch (e) {
  console.error("Validation failed", e);
}

In C#: I used Newtonsoft.Json.Schema for JSON Schema validation.

C#
using Newtonsoft.Json;
using Newtonsoft.Json.Schema;

var schema = JSchema.Parse(System.IO.File.ReadAllText("User.schema.json"));
var json = JsonConvert.SerializeObject(new User { Id = "123", Name = "Alice", Email = "alice@example.com" });
var isValid = JObject.Parse(json).IsValid(schema);

if (!isValid)
{
    Console.WriteLine("Validation failed");
}

These validators ensure that even if something goes wrong during communication, the schema enforces correctness at runtime.

Step 5: Automate the Workflow

To keep everything synchronized, I automated the type and class generation in the CI/CD pipeline. Whenever the schema changes, the pipeline regenerates the TypeScript and C# files, ensuring that both applications stay up-to-date.

For example, in my CI/CD configuration, I included steps like:

  • Running json-schema-to-typescript to generate TypeScript types.
  • Running NJsonSchema to generate C# classes.
  • Running tests to validate data against the schema in both environments.

Conclusion

By defining a shared JSON Schema and using tools like json-schema-to-typescript and NJsonSchema, I created a reliable contract between my TypeScript and C# applications. This approach eliminated guesswork, caught errors early, and ensured that both systems communicated seamlessly. While the initial setup took some effort, the long-term benefits of consistency and type safety made it well worth it.

Share this:

Leave a Reply