DEV Community

Theodor Diaconu
Theodor Diaconu

Posted on

Bulletproof TypeScript: Enforcing Architectural Contracts

In the world of TypeScript development, we often face a difficult trade-off. On one side, we have the desire for dynamic, flexible, and metadata-driven architectures. On the other, we have the absolute necessity of static type safety. All too often, the more "magical" and dynamic a framework's features are, the more we have to sacrifice the compile-time guarantees that make TypeScript so powerful. We end up with any types, stringly-typed APIs, and runtime errors that should have been caught during development.

But what if you could have the best of both worlds? What if you could build powerful, self-configuring systems that are also fully type-safe, from configuration to execution?

Enter BlueLibs Runner, a framework designed from the ground up to resolve this conflict. With Runner, you can implement sophisticated architectural patterns that are both incredibly dynamic and rigorously type-checked. Let's explore two of its most powerful features: Middleware and Tags.

Part 1: Middleware with Contracts - Beyond Simple Execution Wrapping

Middleware is a familiar concept for most developers. It’s a clean way to handle cross-cutting concerns like logging, authentication, or caching. But in most frameworks, middleware is a black box where type safety is often an afterthought.

BlueLibs Runner reimagines middleware as a first-class, type-safe citizen by allowing you to define a strict contract—including Config, Input, and Output types—right at the start.

Enforcing a Full I/O Contract

By passing generics directly to the r.middleware.task<Config, Input, Output>() builder, you create a contract that is enforced on the middleware itself and, more importantly, on any task that uses it.

Let's create a middleware that not only checks for a user's role but also guarantees the shape of the data flowing in and out of the task.

import { r } from "@bluelibs/runner";

// 1. Define the "shape" of our middleware's contract
type AuthConfig = { requiredRole: string };
type AuthInput = { user: { role: string } }; // The task's input MUST have this shape
type AuthOutput = { executedBy: { role: string } }; // The task's output MUST have this shape

// 2. Create the middleware, passing the contract types as generics
const strictAuthMiddleware = r.middleware
  .task<AuthConfig, AuthInput, AuthOutput>("app.middleware.strictAuth")
  .run(async ({ task, next }, _deps, config) => {
    // `config` and `task.input` are now fully typed, no casting needed!
    if (task.input.user.role !== config.requiredRole) {
      throw new Error("Insufficient permissions");
    }

    // The task runs, and its result is also typed
    const result = await next(task.input);

    // The middleware can safely interact with the result and enrich it
    return {
      ...result,
      executedBy: {
        ...result.executedBy,
        verified: true, // Middleware adds its own value
      },
    };
  })
  .build();

// 3. Now, let's use this middleware on a task.
const myTask = r
  .task("app.tasks.myTask")
  .middleware([strictAuthMiddleware.with({ requiredRole: "editor" })])
  // The `run` function's signature is now automatically and strictly enforced!
  // `input` is `AuthInput`, and the return value must be `AuthOutput`.
  .run(async (input) => {
    // input.user.role is available and typed!
    console.log(`Running as ${input.user.role}`);

    // If you return the wrong shape, TypeScript will scream at you.
    // return { wrong: "shape" }; // COMPILE-TIME ERROR!

    return {
      executedBy: {
        role: input.user.role,
      },
    };
  })
  .build();
Enter fullscreen mode Exit fullscreen mode

This pattern is incredibly robust. The middleware is self-documenting and acts as a guardian of your application's data integrity, catching errors at compile time, not at 3 AM in production.

Part 2: Tags as Dynamic, Type-Safe Metadata

Tags are another feature that Runner elevates from a simple labeling mechanism to a cornerstone of dynamic, type-safe architecture. In Runner, tags are not just strings; they are rich, structured, and type-safe metadata objects.

Their primary purpose is to allow different parts of your system to discover and interact with each other at runtime without being tightly coupled.

Let's see this in action by building a system that automatically registers HTTP routes from tasks.

import { r, run, globals } from "@bluelibs/runner";
import express from "express"; // Example server

// 1. Define a structured, type-safe tag for HTTP routes.
const httpTag = r
  .tag<{ method: "get" | "post"; path: string }>("http.route")
  .build();

// 2. Create tasks and annotate them with our new tag.
const getUserTask = r
  .task("app.tasks.getUser")
  .tags([httpTag.with({ method: "get", path: "/users/:id" })])
  .run(async (input: { id: string }) => ({ id: input.id, name: "Jane Doe" }))
  .build();

const createUserTask = r
  .task("app.tasks.createUser")
  .tags([httpTag.with({ method: "post", path: "/users" })])
  .run(async (input: { name: string }) => ({
    id: "user-123",
    name: input.name,
  }))
  .build();

// 3. Create a resource that discovers and registers these routes at startup.
const serverResource = r
  .resource("app.server")
  .init(async () => {
    const app = express();
    app.use(express.json());
    const listener = app.listen(3000);
    console.log("Server listening on port 3000");
    return { app, listener };
  })
  .dispose(async ({ listener }) => listener.close())
  .build();

const routeRegistrar = r
  .hook("app.hooks.registerRoutes")
  .on(globals.events.ready) // Run this when the system is ready
  .dependencies({ store: globals.resources.store, server: serverResource })
  .run(async (_event, { store, server }) => {
    // Find all tasks that have our httpTag
    const tasksWithHttpTag = store.getTasksWithTag(httpTag);

    console.log(
      `Found ${tasksWithHttpTag.length} tasks to register as routes.`,
    );

    for (const taskDefinition of tasksWithHttpTag) {
      // Extract the typed configuration from the tag
      const config = httpTag.extract(taskDefinition);
      if (!config) continue;

      const { method, path } = config; // `method` and `path` are fully typed!

      console.log(`Registering ${method.toUpperCase()} ${path}`);

      // Register the route in Express
      server.app[method](path, async (req, res) => {
        try {
          const input = { ...req.params, ...req.body, ...req.query };
          const result = await taskDefinition(input);
          res.json(result);
        } catch (error) {
          res.status(500).json({ error: error.message });
        }
      });
    }
  })
  .build();

// 4. Assemble the app
const app = r
  .resource("app")
  .register([getUserTask, createUserTask, serverResource, routeRegistrar])
  .build();

await run(app);
Enter fullscreen mode Exit fullscreen mode

This is incredibly powerful. Our server has no direct knowledge of the user tasks, yet it can serve them as API endpoints. We can add, remove, or change routes just by modifying task tags, and the entire system reconfigures itself at startup, all with complete type safety.

Part 3: The Ultimate Combination - Tags as Enforceable Contracts

While middleware is perfect for applying logic, sometimes you just want to enforce a shape or a contract without adding any runtime overhead. This is where "Contract Tags" shine. A tag can be defined to carry an Input and Output contract, which is then enforced on any task that uses it.

Imagine you want to standardize how data processing tasks work in your system. You can define a contract tag for it.

import { r } from "@bluelibs/runner";

// Define a tag that enforces both an Input and Output contract.
// The second generic is Input, the third is Output.
const dataProcessorContract = r
  .tag<void, { rawData: string }, { processed: boolean; timestamp: Date }>("contract.dataProcessor")
  .build();

// This task satisfies the contract.
const processDataTask = r
  .task("app.tasks.processData")
  .tags([dataProcessorContract])
  // The `run` method's signature is now enforced by the tag.
  // `input` must be `{ rawData: string }`
  // The return value must be `{ processed: boolean; timestamp: Date }`
  .run(async (input) => {
    console.log("Processing:", input.rawData);
    return {
      processed: true,
      timestamp: new Date(),
    };
  })
  .build();

// This task fails the contract because its `run` signature is wrong.
const invalidTask = r
  .task("app.tasks.invalid")
  .tags([dataProcessorContract])
  // COMPILE-TIME ERROR on the `run` implementation!
  // The inferred type of 'run' is '(input: { wrong: string }) => { processed: boolean; }'
  // which is not assignable to the contract's '(input: { rawData: string }) => { processed: boolean; timestamp: Date }'.
  .run(async (input: { wrong: string }) => {
    return {
      processed: true,
    };
  })
  .build();
Enter fullscreen mode Exit fullscreen mode

This pattern allows you to enforce architectural consistency at a high level. You can guarantee that any component fulfilling a certain role in your system adheres to a specific data contract, just by applying a tag. It's like creating your own custom, type-safe annotations that are deeply integrated into the framework, but with zero runtime footprint.

Conclusion: Build Code That's Both Smart and Safe

BlueLibs Runner proves that you don't have to choose between a dynamic, flexible architecture and a safe, statically-typed one.

  • Middleware with Contracts transforms middleware from a simple wrapper into a guardian of your data flow, ensuring inputs and outputs are always correct.
  • Type-Safe Tags turn metadata into a powerful tool for building self-configuring systems that are decoupled, extensible, and safe.
  • Contract Tags allow you to define and enforce architectural patterns across your entire application at compile time.

By embracing these patterns, you can move beyond basic function-level type safety and start thinking about architectural type safety. You can write code that is not only smart and flexible but also robust and maintainable.

Ready to give it a try? Check out the BlueLibs Runner documentation and see how these powerful patterns can revolutionize your next TypeScript project.

Top comments (0)