What is joice?
Let's begin by exploring some common use cases and pain points where joice can provide a solution.
You have a Next.JS app and are missing a type-safe way of interfacing between your client code and server code? A way that still allows you use the network tab to intercept messages and a protocol that does not require you to re-learn what you already know?
Other action frameworks, such as trpc (opens in a new tab) or the new Next.JS Server Actions (opens in a new tab) are not an option because you are not developing a backend-for-frontend server? Other services, possibly even your customers, also might want to invoke your server code (you are building a proper API).
Looking at Next.JS, you are already describing your endpoints using the file system in Next.JS. If you are also validating requests and your responses are typed, why is there no easy way to make this information readily available to the client?
This is what joice is doing for you. Using joice, you are still using HTTP as the transmission protocol and (depending on your choices) still using JSON as encoding. You will still be able to use any kind of client to invoke and retrieve information from your server. The goal is that you might even be able to generate OpenAPI specs from your endpoints. And all this while still having a great, fully-typed developer experience (you were worrying about that, right?): To use an endpoint, you can directly import the endpoint from the api tree in your pages directory. And using the appropriate validation middleware and type hints you have pretty much already set up, everything is fully-typed. No joke.
import {Response, get, handler} from "@joice/joice"
type Greeting = {
message: string;
}
export const getGreeting = get(function (req, res: Response<Greeting>) {
res.status(200).json({ message: "I am greeting you" });
})
export default handler();import { getGreeting } from "~/pages/api/greeting";
import { createClient } from "@joice/joice";
const fetchGreeting = createClient(getGreeting);
const Page = () => {
const [greeting, isLoading] = useFetcher(() => {
return fetchGreeting({});
}, undefined)
if (isLoading) {
return "Loading ...";
}
return <div>{greeting.message}</div>
}
export default Page;Type Inference
The following examples aim to showcase the type inference capabilities of joice. While many of these examples build upon the previous ones, I will emphasize the key changes in each case to highlight the enhanced type inference.
Return-Type Annotations
Let's take a closer look at the code snippet we previously showcased, specifically the getGreeting endpoint. In this case, there is already type inference occurring, and the return value is being correctly inferred from the Request<Greeting> annotation.
type Greeting = {
message: string;
}
export const getGreeting = get(function (req, res: Response<Greeting>) {
res.status(200).json({ message: "I am greeting you" });
})The type annotation is passed through the createClient factory call. This results in the creation of a strongly-typed function with a return value of Promise<Greeting>.
import { getGreeting } from "~/pages/api/greeting";
import { createClient } from "@joice/joice";
const fetchGreeting = createClient(getGreeting);
const greeting = fetchGreeting({})
// ^? Promise<{ name: string }>Query, Header and Body Annotations
To address the importance of validating input values (such as query parameters, headers, and request bodies) during runtime, joice offers a solution that incorporates the use of a powerful validation library called zod (opens in a new tab). Zod plays a crucial role as it allows joice to derive types from runtime validation schemas, enabling robust type inference and validation.
To proceed, let's import the optional validate function from @joice/zod to strengthen the typing of the entire HTTP request object.
import { validate } from "@joice/zod";
type GreetingList = {
total: number;
docs: Array<{ message: string }>;
}
const listGreetingsSchema = {
query: z.object({
limit: z.number({ coerce: true }).optional().default(25)
})
}
export const getGreetings = post(validate(listGreetingsSchema, function (req, res: Response<GreetingList>) {
res.status(200).json({ total: 2, docs: [{message: "I am greeting you" }, /* ... */ ]);
}))Great, what happens here? And what is the benefit of that? Defining the validation in the manner demonstrated ensures that the types associated with this endpoint are fully captured. This approach offers several benefits, including the ability to infer two crucial aspects:
-
You might have realized that the type of the
reqis not expressively defined. This is on purpose, so that TypeScript can infer it from the structure of thevalidatecall. In fact,reqis now strongly typed and is defined as{ query: { limit: number } }! The following code will throw a compiler error:pages/api/greeting.tsexport const getGreetings = post(validate(listGreetingsSchema, function (req /* ... */) { console.log(req.query.limitt) // <- this won't compile, we defined limit, but not limitt /* ... */ })) -
The type information about the endpoint is (again) also passed through the
createClientfactory call and adds additional type information to our fetch function:pages/index.tsimport { getGreetings } from "~/pages/api/greeting"; const fetchGreetings = createClient(getGreetings); const greetings = fetchGreetings({ query: { limit: 1 }}) // ^? Promise<{ total: number, docs: Array<{ name: string }> }>And that means that the following call would not compile as well:
pages/index.tsconst greetings = fetchGreetings({ query: { limitt: 1 }}) // <- this won't compile, we defined limit, but not limitt
The same principle applies to headers and the request body. By defining validation schemas on the server side, the derived type structure becomes accessible on the client side as well. This ensures consistency and type safety throughout the entire communication process. It's important to note that, following Next.js conventions, path parameters are considered part of the query. Therefore, on the client side, these values should be provided within the query object. However, the underlying fetch logic of joice is intelligent enough to understand that these values are meant to be incorporated into the URL when making the request. As a result, path parameters will be appropriately applied to the corresponding path placeholders and won't be included in the query.
By adopting this approach, joice streamlines the communication between the client and server while ensuring accurate type inference and validation. This allows for a seamless development experience and reduces the likelihood of errors or inconsistencies in data transmission.