β€’

newsletter

Building a type-safe bridge between client and server

Client and server are calm and relaxed when they don't have to deal with each other. But in production they do, and so it's better to find ways to collaborate. Here is how to resolve the conflict (type) safely.


Sandro Maglione

Sandro Maglione

Software development

I embarked on a journey into server territory, after years in the valley of client-only πŸšΆβ€β™‚οΈβ€βž‘οΈ

The landscape on the server side looks beautiful, and shares most of the same views as the client.

It's the middle land that it's messy πŸ˜Άβ€πŸŒ«οΈ


Server is easier (on the surface)

In my new Paddle Payments project I implemented both client and server in the same monorepo.

The server has a single src folder, few dependencies, everything easy and simple.

The client is a mess of configuration files, components, routes, services, and more πŸ₯²

Whereas the server has a single entry point in a src folder, the client bundles together countless layers and complexity.Whereas the server has a single entry point in a src folder, the client bundles together countless layers and complexity.

Yet, this view is unfair. The server hosts a more insidious beast: infrastructure. Database, providers, resource management, deployment. It's been a nightmare to debug problems with ports and misconfigurations 🀯

After all, on the client I just bundle a bunch of static files, put them somewhere, and it just works πŸ’πŸΌβ€β™‚οΈ

Don't install, use containers

Just a reminder for the folks that never ventures in Docker's land: do it, life is easier on the other side 🐳

I used to struggle with local installs, hacking my way in a somehow working setup. Something that works, and no one should never touch again.

Then came Docker, and a single file rescues the day. I now spin up a local database and admin panel with a single command docker compose up:

docker-compose.yaml
name: app_docker

services:
  postgres:
    env_file: .env
    container_name: postgres
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PW}
      - POSTGRES_DB=${POSTGRES_DB}
    ports:
      - 5435:5432

  pgadmin:
    env_file: .env
    container_name: pgadmin
    image: dpage/pgadmin4:latest
    environment:
      - PGADMIN_DEFAULT_EMAIL=${PGADMIN_MAIL}
      - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PW}
    ports:
      - 5050:80

How do you go from here to there, and back again?

As long as you stay on the client, or stay on the server, it's calm and tranquil.

It's bridging the gap that comes with the hassle 😬

Here is some of the dragons you may need to slay along the path:

  • TCP
  • Multi part requests
  • Streaming
  • Sockets
  • Syncing
  • Serialization
  • Caching

The mess is all in the middle 🀦

Let's not be pessimistic though, there are some solutions.

When client and server are aligned

If you have the client in the same language and repo as the server, they can join forces πŸ‘‘

In my new Paddle Payments project I have a shared api-client definition used by both server and client:

  • Server implements the API
  • Client derives a type-safe interface

This is as close as "building a bridge" that you can get, with solid materials all based on "type-safety".

RPC: a single function trip

RPC: Remote procedure call.

Sounds fancy? Wikipedia sends you to Procedural programming, just to then mention that "procedures" are also called "functions" πŸ™Œ

RPC puts all the burden of the client/server/client trip on a single endpoint, abstracted as a single function call on the client.

export const signIn = ({
  context,
  event,
}: {
  context: Context.Context;
  event: Events.SubmitForm;
}) =>
  Effect.gen(function* () {
    // πŸ‘‡ A service that abstracts calls to the server
    const client = yield* RpcClient;

    const { preventDefault } = yield* HtmlFormEvent.HtmlFormEvent;
    yield* preventDefault;

    // πŸ‘‡ The server is just a function call away
    return yield* client(
      new SignInRequest({
        email: context.form.email,
        password: context.form.password,
      })
    );
  }).pipe(Effect.provide(HtmlFormEvent.HtmlFormEvent.react(event.event)));

That's what Server functions in React do as well, mask the server as a function on the client (Note: React just renamed "Server actions" to "Server functions" if you didn't know)

Last week I published a complete RPC client/server configuration snippet with Effect. Enjoy πŸͺ„

OpenAPI: server to client translator

If your server is so unfortunate to not be TypeScript, OpenAPI is your only rescue.

OpenAPI it's a mediator that hints the client on what the server expects. Usually speaking the language of YAML. It's a good compromise, and you can play around to make it as type-safe as possible.

I have also a snippet on making a fully type-safe OpenAPI client with Effect. Enjoy πŸͺ„

Sync engine magic

A clever solution is skipping the server, isn't it?

The promise of a sync engine is to be just a pipe between clients. The client messes around with its data locally, and then wreaks havoc on other clients by pushing its updates. The sync engine steps in to mitigate the damage (hopefully).

That's the promise of local first. I have written more about it: The State of Local-First


We are not hopeless, as long as we take the glorious vow of type safety πŸͺ½

Effect is solving the issue on the server (and client alike). On the client instead we are moving closer. Check out TanStack Start for a promised type-safe client.

Meanwhile, this week I published another full project: Paddle Billing Payments Full Stack TypeScript App

See you next πŸ‘‹

Start here.

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.