The problem:

At my company Conio we have a internal backoffice used for user management. But we need to provide the same dashboard to our partners so that they can manage their user base. Unfortunately each partner is different in "UI" and "Api request/response":

For the first group I can split in:

For the second group I can split in different scenario but this article will cover only a scenario where the api request for a getUser (with different response and different request since we use GraphQL:

// response on /api/PartnerX/getUser
export interface Order {
  id: string
  name: string
  isOtc: boolean
}

//response on /api/partnerY/getUser
export interface Order {
  id: string
  name: string
}

This is a common scenario because normally a feature comes out in before in our backoffice and later (also years) in partners.

We can also think to put isOtc as optional field. This way is fast and simple. But after we need to add typescript check even if we know that isOtc is defined. And typescript code can explode in a scenario with others 20 apis and 30 different others interfaces.


The solution (my solution):

The solution I will propose fit my requirements and my goal. I don't think is a perfect solution. Every idea is a tradeoff with pro and cons.

My pro points are:

My cons points are:


So, my backoffice project is a pure react client app (no server side) and is built on top of vitejs.

I have a SINGLE vite config:

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import path from "path";

export default defineConfig(({ mode }) => {
    const mapping = {
        partnerX-staging: "X",
        partnerX-prod: "X",
        partnerY-staging: "Y",
        partnerY-prod: "Y",
    };

    const partner = mapping[mode] || process.env.MODE;

    if (!partner) {
        throw new Error(`Partner not found for mode: ${mode} in mapping`);
    }

    return {
        plugins: [react()],
        envPrefix: "REACT_APP",
        resolve: {
            alias: {
                src: path.resolve(__dirname, "src"),
                "@partner": path.resolve(__dirname, `src/packages/partners/${partner}`),
            },
        },
        build: {
            outDir: "build",
        },
        server: {
            port: 3000,
        },
        preview: {
            port: 3000,
        },
    };
});

In details:


And my package.json is:

{
  "name": "backoffice",
  "version": "1.34.1",
  "private": true,
  "scripts": {
    "set-partner": "node setup-ts-config.js --partner",
    "type-checking": "tsc",
    "build": "vite build",
    "build:env": "yarn run type-checking && yarn run build --mode",
    "build:partnerX-prod": "yarn set-partner partnerX && yarn run build:env partnerX-prod",
    "build:partnerX-staging": "yarn set-partner partnerY && yarn run build:env partnerX-staging",
    "build:partnerY-prod": "yarn set-partner partnerY && yarn run build:env partnerY-prod",
    "build:partnerY-staging": "yarn set-partner partnerY && yarn run build:env partnerY-staging",
    "start": "yarn set-partner partnerX && vite --mode partnerX-staging",
  }
}

In details:


My setup-ts-config.js is

const fs = require("node:fs");
const args = process.argv.slice(2);
const partnerIndex = args.findIndex((arg) => arg === "--partner");
const partner = partnerIndex !== -1 ? args[partnerIndex + 1] : null;

if (!partner) {
    throw new Error("Please provide a partner name using --partner <partner-name>");
}

const tsConfig = JSON.parse(fs.readFileSync("tsconfig.json", "utf-8"));

tsConfig.compilerOptions.paths = {
    "@partner/*": [`src/packages/partners/${partner}/*`],
};

fs.writeFileSync("tsconfig.json", JSON.stringify(tsConfig, null, 2), "utf-8");

And I have N files with name ".env.${partner} like:


Now I can show you a common interface

// src/packages/core/user/types.ts
import { UserX } from "src/packages/partners/X/user/types";
import { UserY } from "src/packages/partners/Y/user/types";

export interface BasicCommonUser {
  id: string
  name: string
}

export type User = UserX | UserY;

And I can have specific interface for X

// src/packages/partners/X/user/types.ts
import { BasicCommonUser } from "src/packages/core/user/types";

export interface UserX extends BasicCommonUser {
  isOtc: boolean
}

And I can specific interface for Y (for example obsoleted interface)

// src/packages/partners/Y/user/types.ts
import { BasicCommonUser } from "src/packages/core/user/types";

export interface UserY extends BasicCommonUser {}

In this way we can use User like:

// src/components/MyWidgetUser.tsx

import { User } from "src/packages/core/user/types";

export default function(user: User){
 return <p>hi {user.name}</p>
} 

In this way when we run/build with partner X, we can write "user.name" and with partner Y "user.name" generates type error 😀.

If we want more control, for example we can Typescript TypeGuards:

// src/packages/core/user/types.ts

//... previus code

export const isUserX = (user: User): user is UserX => {
    return (
        "isOtc" in user
    );
};

and use like that:

// DynamicWidgetUser.tsx
export default function(user: User){
  if(isUserX(user)){
    return <p>{user.name} is otc: {user.isOtc}</p>
  } else {
    return <p>{user.name}</p>
  }
}

I love GrapQL and we use it. But since UserX has got "isOtc" in the real work the queries from X and Y are different.
So in my case for X I have

// src/packages/partners/X/getUser.ts
export const GET_USER = () => gql`
  fragment UserSchemaFields on UserSchema {
    name
    isOtc
  }
  query user {
    user(user_id: $user_id) {
        ...UserSchemaFields
    }
}  
`

and for Y I have


// src/packages/partners/Y/getUser.ts
export const GET_USER = () => gql`
  fragment UserSchemaFields on UserSchema {
    name
  }
  query user {
    user(user_id: $user_id) {
        ...UserSchemaFields
    }
}  
`

I use this api like that:

// src/components/Header.tsx
import { GET_USER } from "@partner/getUser";

export default function(){
  const { data } = useQuery(GET_USER)
}

And this works since "@partner" points to one of existing folder (tsconfig.json knows what)

All this code show you how much customization you can do with this configuration. In my real work UserX has got more than 15 fields and UserY about 10. So X has got 5 more field and 3/5 are optional 😀.