Yan Sun I am a full-stack developer. Love coding, learning, and writing.

Phero: Build a type-safe, full-stack app with TypeScript

7 min read 2086

Editor’s note: This post was updated on 29 December 2022 to include mention of new features.

Syncing backend API changes to the client app is a common problem frontend developers face. When the backend API interface is changed, the client app may need to be manually updated to prevent an error or crash.

Phero is designed to solve this problem. Developed by the people at Press Play in 2020, it was initially created to manage types and interfaces for their software product, which consumes a large number of API endpoints. Phero is developed using TypeScript and works with any framework based on TypeScript. It will process the backend code and generate a type-safe client for the frontend.

Using Phero, you can call the backend services/functions from the frontend just as you would call a local function. It’s been open source since September 2022.

Let’s take a closer look at Phero.

Jump ahead:

What problem does Phero solve?

Let’s use a typical Node.js/Express web API as an example. We expose an endpoint like below:

# API
export enum UserRole {
  ADMIN = "admin",
  USER = "user",
}
export interface User {
  name: string;
  age: number;
  role: UserRole;
}
export interface Users {
  [key: number]: User;
}
app.get("users/:userId", (req, res) => {
  res.send(
     {
      name: "Jane",
      age: 30,
      role: UserRole.USER
    });
});

In the client app, we need to create another set of type/interfaces and then cast the response JSON to get a typed response object.

# Client App
export enum UserRole {
  ADMIN = "admin",
  USER = "user",
}

export interface User {
  name: string;
  age: number;
  role: UserRole;
}

const response = await fetch(`${Root_URL}/users/1`);
const user = await response.json() as User;

There are a few issues with this pattern. Firstly, there are duplicated types and interfaces on both the client and server sides. This duplication will make the app hard to maintain.

Secondly, when the API changes (i.e., a new UserType is added), the client app may crash if it’s not updated — and even if you did update the client, the older version of the client app will still have the same problem. Thus, both the server and client need to be updated at the same time, to ensure backward compatibility.

To overcome the above issues, one common practice is to add a JSON response to the client code. But the extra validation requires additional work and is not safe enough because it can’t catch issues at compile time.

Phero provides a clean solution to this problem.

How does Phero work?

Under the hood, Phero makes use of remote procedure call (RPC). RPC is a communication protocol that allows a program on one machine to call a service on another machine without knowing it’s remote.

The below diagram shows the Phero client and service communication using RPC.

A diagram illustrating the connection between Phero and the client via RPC
A diagram illustrating the connection between Phero and the client via RPC

You can use any TypeScript-based framework to build your backend and frontend; otherwise, a Phero-enabled app comprises three components:

  1. The client app: Your client app just needs to consume the generated Phero client file when interacting with the server
  2. The server app: The entry point of the server component is Phero.ts; you also have the flexibility to organize the server project structure as long as the Phero file exposes the functions
  3. Phero-generated code: The generated files are the glue between the backend and frontend, and include the client-side stub and server-side stub files. The stubs enable the server and client to communicate via RPC, using the HTTP protocol

Phero makes use of the TypeScript compiler API to analyze types and interfaces. It also generates the validator to parse the incoming and outgoing data between the client and server. It exposes all the domain types and interfaces exposed by the backend; thus, a compile-time error will be thrown if a typo or incorrect data is returned.



Benefits of working with Phero

The benefits of Phero include:

  • Pure TypeScript (no other dependencies required)
  • End-to-end type safety
  • Separate backend and frontend that act like they are not separated
  • Work with any TypeScript-based framework

Getting started with Phero

Let’s walk through the process of building a Phero app. Since Phero is a pure TypeScript library, the only dependencies are npm and TypeScript.

Backend setup

Firstly, let’s initialize a Node.js app. We create an api directory below. It can be named anything you wish.

# bash script
#  Create a Server folder
mkdir api
cd ./api
# Initialise the server app, and get TypeScript ready 
npm init -y
npm install typescript --save-dev
npx tsc --init

​# edit tsconfig.json file to add the following entries
# it is a temporary workaround until the bug is fixed in Phero CLI
{
 "compilerOptions": {
   ...
   "rootDir": "src",
   "outDir": "dist"
 }
}
# Add Phero to your backend project
npx phero init server

After the above commands are completed successfully, you will have a new phero.ts file in the api/src folder.

# api/src/phero.ts
import { createService } from '@phero/server'

async function helloWorld(name: string): Promise<string> {
  return `Hi there, ${name}!`
}

export const helloWorldService = createService({
  helloWorld
})

The generated Phero.ts file exposes a helloWorld service and is ready to be consumed by the client.

Frontend setup

Let’s run the following script to set up the client. We create an app subfolder, but it can be named something else. The Phero CLI can scan for dependencies to determine whether a project is a phero-server or phero-client project.

# bash script 
# from your project root directory, create a app subfolder 
mkdir app
cd ../app

# initialise a node app
npm init -y
npm install typescript --save-dev
npx tsc --init

# Add Phero to your frontend project:
npx phero init client

The phero init client command installs the @phero/client and generates a Phero.ts file.

# app/src/Phero.ts
import { PheroClient } from "./phero.generated";

const fetch = window.fetch.bind(this);
const client = new PheroClient(fetch);

async function main() {
  const message = await client.helloWorldService.helloWorld('Jim')
  console.log(message) // `Hi there, Jim!`
}

main()

Run the CLI to start the dev environment

Now we can run the Phero CLI to start the development environment.

# back to the project root directory
cd ../
npx phero

The above command starts the server and generates an SDK for the frontend (or multiple frontends) at the same time. You should see the following output in the console:

The output of the Phero SDK installation in the console
The output of the Phero SDK installation in the console

In the frontend project, a phero.generated.ts file will be generated. It’s the RPC client stub used to connect to the server stub. As the name indicates, it shouldn’t be manually updated. It’s automatically synced with any server-side change when the CLI is running.

Although the basic setup is completed now, we can’t run the client to prove that it’s working because we don’t have a proper client app to run in a browser yet.

Let’s build a React client app to test it end-to-end.

Creating a React client app

Run the following command to generate a skeleton of a React app.

npx create-react-app react-app --template typescript

Then, you can start the app as below.

cd react-app
npm start

Now we have a working React app, so the next step is to add Phero to it.

# bash script
npx phero init client
cd .. # Back to project root directory
# run Phero CLI to start the dev environment
npx phero

The above command will generate the phero.generated.ts file in the React project.

The last step is to open the src/App.tsx and replace the original code with the following code snippet:

import { PheroClient } from './phero.generated';
import { useCallback, useEffect } from "react";

const phero = new PheroClient(window.fetch.bind(this))

function App() {
  const getHello = useCallback(async () => {
      const message = await phero.helloWorldService.helloWorld('Jim');
      console.log(message);
  }, [])

  useEffect(() => {
    getHello()
  }, [getHello])

The above code snippet will initialize a PheroClient, and makes a call to the helloWorldService.helloWorld method.

To verify the app is working, open http://localhost:3000/ and open Chrome DevTools. You can see the console message and the client-to-server RPC call in the Network tab.

The client-server RPC call as shown in the Network tab
The client-server RPC call as shown in the Network tab

You can find the related source code in the GitHub repo.

Error handling with Phero

Using Phero, we can handle the custom server-side error in our frontend just like we would a local function.

Let’s keep going with the previous API example. if a user can’t be found by their given user ID, we want to throw a UserNotExistException error on the backend.

class UserNotExistException extends Error {
  constructor(userId: number) {
    super()
  }
}
app.get("users/:userId", (req, res) => {
    const user = db.getById(userId);
    if(user === null) {
      throw new UserNotExistException(userId);
    }
    return user;    
});

Then at the frontend, we can handle the error as below. Please note that the UserNotExistException is exposed in the client by the generated Phero file.

import {
 PheroClient,
 UserNotExistException,
} from "./phero.generated"

const fetch = window.fetch.bind(this)
const client = new PheroClient(fetch)
 try {
   const user = await client.userService.getUser(1)
 } catch (error) {
   if (error instanceof UserNotExistException ) {
     console.log("User not found")
   } else {
     console.log("Unexpected error")
   }
 }

Again, we gain type-safety in error handling with Phero. In addition, the userId is populated as a property of the error on the server side, and it is accessible on the client side via error.userId.

Deploying to production

At the local development environment, the default Phero server API runs at port 3030. When deploying to a higher environment or production, you may need to use a different port number. Same as any Node.js API, the port number is configurable by passing in an environment variable PORT at runtime or using a .env file.

For example, to run the server API at port 8080:

cd ./.build
PORT=8080 node index.js

In a non-local environment, you may also need to make the API URl configurable on the client side. We can store the URl in an environment variable and pass the URL to PheroClient as below.

import { PheroClient } from "./phero.generated"
const client = new PheroClient(fetch, `${API_ROOT}`)

To build a bundle of server code for deployment, run the command below.

cd ./server
npx phero server export

This will generate the bundle files into a .build directory. The server can be started with a standard Node.js command.

At the moment, the Phero server can run on any Node-based server. The following new export and deploy features for are available for different cloud platforms:

Migrating an existing app to use Phero

If you want to migrate an existing server app to use Phero, you may want to restructure your Phero app first.

Phero doesn’t come with lots of restrictions. You just need to define the Phero.ts as the entry point of the backend app and export your services from the Phero file. You can design your services and functions using any TypeScript-based framework.

Let’s say we need to migrate a Node.js/Express API app to Phero. We need to define clear “groups” of functions that need to be called from the frontend. In Express, you would typically group them in “Routers“. We can covert these groups into services in Phero. All routes you currently have inside these routers can be migrated to regular TypeScript functions.

As an example, this function:

router.post('/example-route', (req, res) => {
  const data: MyData = req.body

  res.send({ output: 123 })
})

Would become the following after router migration:

async function exampleFunction(data: MyData): Promise<{ output: number }> {
  return { output: 123 }
}

When designing the new Phero service interface, you should think about the types. If you’re only using primitive types like string and number, the type safety that comes with Phero is probably overkill for you. Defining well-constrained types in the Phero backend will help you detect errors in compile time at the frontend.

Summary

Phero is a type-safe glue between the backend and frontend. It’s a viable solution for ensuring better type safety in frontend development. Using Phero, you can call backend functions or handle backend errors from the frontend with end-to-end type safety. It will help you to write more concise code and have peace of mind in refactoring.

I hope you find this article useful. Happy coding!

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Yan Sun I am a full-stack developer. Love coding, learning, and writing.

Leave a Reply