My Journey
I have come a long way to get here. I have always wanted to share types between the frontend and the backend. I was never satisfied with existing solutions such as using Swagger. In every greenfield project, I built a framework for sharing endpoint request and response types with proper, type-safe error handling. Once I got far enough, I wrote an API client generator with React integration and so on. And I was constantly searching for better alternatives. I found a Chinese library that could share auto-inferred response types. Later, I found tRPC, which was much better. It was something I had already imagined. I got excited, tried it, and unfortunately, I got disappointed because my existing solutions were better in many ways. Conceptually, it was not built upon the principles I had started to believe in during my career:
- I use discriminated unions a lot.
- I don’t use “throw” for user errors.
- I avoid “any” types.
- I don’t like type casting.
- I prefer pure functions without side effects and mutations.
- I prefer to pass dependencies manually and explicitly instead of using an injector.
- And so on …
I have my own reasons, but these are my preferences, and I don’t like it when a framework doesn’t let me do the job the way I would like to. tRPC didn’t let me. But hey, that’s a good thing because it led me to start my own project: Cuple, which I managed to finish quite quickly, and we are already using it in production! I managed to make it really great to work with. And it doesn’t get in your way. I’ve been using it for a while now, and I can’t express how much pride I have in it. Check out this example:
const authLink = builder
.headersSchema(
z.object({
authorization: z.string().startsWith("Bearer "),
}),
)
.middleware(async ({data}) => {
const token = data.headers.authorization.replace("Bearer ", "");
try {
const user = await auth.verifyIdToken(token);
return {
next: true as const,
authData: {
firebaseUserId: user.uid,
},
};
} catch (e) {
return apiResponse("forbidden-error", 403, {
next: false as const,
message: "Bad token",
});
}
})
.buildLink();
This is an authLink
which contains schema validation for the headers (yes, you can have type-safe headers!) and middleware that shares authData
if the user is logged in.
One of the biggest problems with plain express, is that it is not built for typescript. With plain express you mutate the request in the middleware if you want to share data down the line. Mutation is unsafe, and not type-checkable. I solved this by having an endpoint builder. This might look strange at first, but trust me, this works really well!
You just have to chain this link wherever you want. See:
{ /* ... */
addUser: builder
.chain(authLink)
.bodySchema(
z.object({
email: z.string().email(),
firstName: z.string().min(1),
lastName: z.string(),
industryId: z.number(),
organization: z.union([
z.object({
action: z.literal("Join"),
code: z.string(),
}),
z.object({
action: z.literal("Create"),
name: z.string(),
}),
]),
}),
)
.middleware(async ({data}) => {
const org = data.body.organization;
if (org.action === "Join") {
const payload = invitationService.parseCode(org.code);
return {
next: true,
invitationData: {
orgId: payload.orgId,
},
};
}
return {
next: true,
};
})
.post(async ({data}) => {
const org = data.body.organization;
await userService.addUser({
email: data.body.email,
firebaseUserId: data.authData.firebaseUserId, // coming from authLink
firstName: data.body.firstName,
lastName: data.body.lastName,
industryId: data.body.industryId,
organization:
org.action === "Join"
? {
action: "Join",
id: data.invitationData!.orgId, // coming from the middleware above
}
: {
action: "Create",
name: org.name,
},
});
return success({message: "User has been created successfully"});
}),
}
There are more things going on here.
You can keep adding middlewares, schema validations for query, body, headers, even for path parameters, if you need custom path.
Every step’s exposed data is type-safely accessible. Check this out:
Would you like that much type safety in your project? Of course you would!
It’s done right on client-side too!
export const client = createClient<Routes>({
path: prefix + '/api/rpc',
});
const response = await client.user.addUser.post({
headers: {
authorization: `Bearer ${localStorage.getItem('firebaseIdToken')}`,
},
body: {
email: 'some@body',
firstName: 'Test',
lastName: 'Test',
industryId: 1,
organization: {
action: 'Create',
name: 'My Org',
},
},
});
Remember we need headers because of the authLink
? Yes, it’s type-checked client-side! But why not use a new client that includes that automatically?
export const client = createClient<Routes>({
path: prefix + '/api/rpc',
});
export const authedClient = client.with(() => {
return {
headers: {
authorization: `Bearer ${localStorage.getItem('firebaseIdToken')}`,
},
};
});
const response = await authedClient.user.addUser.post({
body: {
email: 'some@body',
firstName: 'Test',
lastName: 'Test',
industryId: 1,
organization: {
action: 'Create',
name: 'My Org',
},
},
});
And the response type is a discriminated union of the possible responses auto-inferred from the endpoint:
const response:
| ZodValidationError<{ authorization: string; }>
| { result: "success"; statusCode: 200; message: string; }
| { result: "unexpected-error"; statusCode: 500; message: string; }
| { result: "forbidden-error"; statusCode: 403; message: string; }
| ZodValidationError<{
organization: {
code: string;
action: "Join";
} | {
action: "Create";
name: string;
};
email: string;
firstName: string;
lastName: string;
industryId: number;
}>
Conclusion
If I join a project, I would love to see Cuple being used. I want to work with it, and I am confident that I will use this for future projects. I hope you enjoyed reading.
Quick start: https://github.com/fxdave/react-express-cuple-boilerplate
More examples: https://github.com/fxdave/cuple/tree/main/test/src/examples/auth
Project: https://github.com/fxdave/cuple
Top comments (1)
It's usefull! thanks