Tired of rewriting the same TypeScript types over and over? We get it—it’s frustrating, time-consuming, and downright inefficient. If you’re working in a monorepo or a Backends-for-Frontend (BFF) setup, you know how quickly things can spiral out of control.
But don’t worry—we’ve got you covered. In this guide, we’ll tackle the common pain points of managing TypeScript types in these environments, share proven solutions, and show you how to set up shared types that boost your project’s stability.
And, of course, we’ll pick up some new tricks to become the laziest, most efficient developers we can be.
The problem :
In many modern monorepos, we often encounter the following structure:
project-root/
├── client/ # React frontend (TypeScript)
│ ├── src/ # Source code for React app
│ │ ├── types/ # TypeScript types for the frontend
│
├── server/ # Express backend (TypeScript)
│ ├── src/ # Source code for backend
│ │ ├── types/ # TypeScript types for the backend
│
In a typical monorepo setup, the client and server maintain their own types/ folders, leading to several challenges:
Duplication: Identical types are defined and maintained separately, creating unnecessary effort and redundancy.
Scaling Issues: As the monorepo grows, syncing types across multiple services becomes harder.
Inconsistencies: A backend change (e.g., modifying an API response) doesn’t automatically reflect in the front end, risking runtime errors.
Delayed Feedback: Issues from type mismatches often surface late, during testing or deployment, slowing down development.
This lack of shared context undermines TypeScript’s ability to ensure consistency and increases maintenance overhead.
Existing Solutions and Their Drawbacks
End-to-End (E2E) Tests
• Pros: E2E tests can catch mismatches between client and server responses.
• Cons:
• Not all changes are easily caught.
• They are expensive to maintain and execute.
• Debugging failures can be complex.
• We still have delayed feedback
Separate Repository for Types
• Pros: Centralized types ensure consistency.
• Cons:
• Requires frequent updates to the shared repo.
• Developers must manually synchronize dependencies (npm install or similar) across repos.
While these solutions improve our code, they’re not without their quirks. E2E tests are like that friend who always shows up late—reliable in the end, but frustratingly slow. On the other hand, a separate repo feels more responsive, like grandma answering the phone on the first ring.
What we really need is a solution that combines the best of both worlds: TypeScript’s lightning-fast feedback loop and a structure that ensures long-term maintainability.
Our Solution: Shared Types in the Monorepo
To address these issues, we added a shared-types folder at the root of our monorepo. This folder is accessible to both the client and server.
Updated Project Structure
project-root/
├── client/
├── server/
├── shared-types/ # Shared TypeScript types
│ ├── index.d.ts # Entry point for shared types
│ ├── users/ # Folder for user-related types
│ │ └── index.d.ts
│ ├── files/ # Folder for file-related types
│ │ └── index.d.ts
....................
....................
.....................
Updated TypeScript Configuration
We updated the tsconfig.json files for both the client and server to include the shared-types folder:
//tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"my-types/*": ["../shared-types/*"]
},
"typeRoots": ["./node_modules/@types", "../shared-types"]
},
"include": ["src/**/*", "../shared-types"],
"exclude": ["node_modules", "dist"]
}
//PLZ don't copy this file as is .
//Each project have it's own setup.
Building the Shared Typing Library
With the groundwork for imports in place, the real challenge lay ahead: building the shared typing library.
The Root index.d.ts: Centralizing Shared Types
The first step is creating a root index.d.ts
file within the shared-types
directory. This file acts as the central entry point for managing all imports and exports in the library, providing a single source of truth for shared types.
By organizing types into logical groups—such as user-related and file-related types—it ensures clarity and accessibility throughout the monorepo. This structure not only simplifies type management but also promotes consistency and reduces redundancy across the project.
Here’s how we structured the root file:
/// <reference path="./users/index.d.ts" /> //here we import
/// <reference path="./files/index.d.ts" />
declare module "my-types" {
export { UsersTypes } from "my-types/users"; // here we export
export { FilesTypes } from "my-types/files";
}
Some Background
Before we dive in, let’s quickly refresh our memory on what namespaces are in TypeScript. If you’re already a pro, feel free to skip ahead—we won’t be offended (much).
Namespaces
Namespaces are a TypeScript feature that allows you to group related code (variables, functions, classes, or interfaces) under a single name, avoiding global namespace pollution. They exist only during compile time and are not included in the runtime JavaScript output.
Here’s a simple example
namespace Utils {
export function greet(name: string): string {
return `Hello, ${name}!`;
}
export const VERSION = "1.0.0";
}
// Accessing members of the namespace
const message = Utils.greet("Alice");
console.log(message); // Outputs: Hello, Alice!
console.log(Utils.VERSION); // Outputs: 1.0.0
Key Points
• Purpose: Organize related code and prevent naming conflicts.
• Scope: Wraps all members in a named block.
• Declaration: Defined using the namespace keyword.
• Access: Members are accessed with dot notation (e.g., Utils.greet).
Namespaces are particularly useful for organizing large projects or legacy codebases.
Lets go to the actual typing
The UsersTypes namespace encapsulates all user-related types, with a sub-namespace http that organizes types by HTTP methods and routes. For example, the post namespace represents the POST /users route, defining both req (request) and res (response) types.
declare module "my-types/users" {
export namespace UsersTypes {
export namespace http {
export namespace post {
/**
* Request body for creating a new user
*/
interface req {
id: string;
user: IUser;
}
/**
* Response body after creating a new user
*/
interface res {
status: IStatus;
user: IUser;
}
}
}
/**
* User model shared between client and server
*/
export interface IUser {
id: string;
name: string;
age: number;
}
/**
* Status values for user-related actions
*/
export type IStatus = "active" | "inactive" | "pending" | "deleted";
}
}
Now, let’s put the shared types to work in the client. Below is an example of a simple userService
function that handles API calls.
Sure, real-world services are often longer and more complex, but trust me—this approach scales beautifully, and the more complex the service, the more it shines!
import axios from "axios";
import { UserTyp } from "my-types/files";
const baseRoute = "/users";
//typed request body
const createUser = async (body: Users.shared.http.post.req) => {
try {
const response = await axios.post<Users.shared.http.post.res>(
baseRoute,
body
);
return response.data; // Typed response data
} catch (e) {
...
}
};
On the server side, we implement a straightforward controller that utilizes the shared types
import { userService } from "./userService";
import type { Request, RequestHandler, Response } from "express";
import { handleServiceResponse } from "@/common/utils/httpHandlers";
import { UsersTypes } from "my-types";
class UserController {
public createUser: RequestHandler = async (
req: Request,
res: Response<UsersTypes.http.post.res>//enforce the respose body
) => {
const body: UsersTypes.http.post.req = req.body;//enforce the request body
const serviceResponse = await userService.createUser(body);
return handleServiceResponse(serviceResponse, res);
};
}
export const userController = new UserController();
At this point, we’ve got strong type enforcement running the show on both the client and server. TypeScript is now like that overly cautious friend who catches every little mistake before it causes chaos—ensuring consistency and stability across the board.
But here’s the catch: what if you need to create types that are unique to one repo (say, the client) while still reaping the benefits of shared types? And wouldn’t it be great if we could avoid littering the codebase with repetitive imports?
The fix? Extend the typing library and tap into the magic of a global namespace. For example, you can add a new file in the client-specific types library, like client/src/types/users/users.d.ts
. This way, you can extend shared types seamlessly, keep your code neat, and save yourself from the monotony of redundant imports. It’s clean, it’s efficient, and it’s exactly what we need.
declare namespace Users {
export { UsersTypes as shared } from "my-types";
export interface ClientIUser extends UsersTypes.IUser {
org: string;
}
}
We placed our shared types under a shared namespace, letting us easily extend the shared User
type into a client-specific version.
No more repetitive imports, a cleaner codebase, and a clear split between shared and client-specific types—everyone wins.
Now, let’s look at the new userService
:
const createUser = async (body: Users.shared.http.post.req) => {
try {
const response = await axios.post<Users.shared.http.post.res>(
baseRoute,
body
);
const user: Users.ClientIUser = { ...response.data, org: "ORG" };
return user;
} catch (e) {
...
}
};
How We Leveled Up
By adding shared types, we unlocked:
- Stability: TypeScript now catches inconsistencies faster than your QA team ever could, saving you from production nightmares.
- Maintainability: With centralized types, you can say goodbye to duplication and hello to painless updates.
- Efficiency: Developers can now make changes with swagger, knowing shared types have their back and keep everything compatible.
But why stop here? Take it up a notch by extracting TypeScript types directly from Zod schemas—a library for runtime data validation that doubles as a TypeScript wizard using z.infer
to generate static types. You can also extend your coverage by defining interfaces for errors and route parameters, making your codebase not just type-safe but basically future-proof.
Top comments (0)