RFC - End-to-End type safety #4978
Replies: 3 comments 8 replies
-
Quick update regarding request and response types: import type { Routes, Paths } from '~/generated/routes'
Routes['users.store']['Request']
Routes['users.show']['Response']
Routes['users.show']['Params']
Paths['/users/:id']['Request']
Paths['/users/:id']['Response']
Paths['/users/:id']['Params'] The issue with using Paths['/users/:id']['GET']['Params'] But that starts to feel a bit clunky, too many index levels (subjectively). Thats why we are suggesting to go with a namespace + generics approach instead: type UserShowRequest = Path.Request<'GET', '/users/:id'> Feels a bit cleaner. Of course, subjective, so open to feedback. type UserStoreRequest = Route.Request<'users.store'> So the final proposed API would look like this: // Named routes
type UserStoreRequest = Route.Request<'users.store'>
type UserStoreResponse = Route.Response<'users.store'>
type UserStoreParams = Route.Params<'users.store'>
type UserStoreErrors = Route.Errors<'users.store'>
// Raw URLs
type UserShowRequest = Path.Request<'GET', '/users/:id'>
type UserShowResponse = Path.Response<'GET', '/users/:id'>
type UserShowParams = Path.Params<'GET', '/users/:id'>
type UserShowErrors = Path.Errors<'GET', '/users/:id'> |
Beta Was this translation helpful? Give feedback.
-
I don't know if I didn't understand, so feel free to correct me From the RFC, we can understand that multiple controllers can render the same page, and that's correct But when I see return interia.render('posts/index', {
posts: transform(posts, PostTransformer),
user: transform(auth.user, UserTransformer)
}) and my Page looks like this: import Data from '~/data'
export function PostsIndex(
props: { posts: Data.Post[] }
) {
props.posts
} Well, we're sending more data than the page needs But if we had something like this return interia.render('posts/index', {
user: transform(auth.user, UserTransformer)
}) This should indeed have a type error since we're not sending posts. My train of thought here is the frontend is the one that knows what the page needs to correctly render, so if the backend doesn't send all of the data, it should error out Is this what you are thinking as well? |
Beta Was this translation helpful? Give feedback.
-
This sound really nice specially for My only question is more related to Moving to |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
End-to-end type safety refers to having a single source of truth for your types across the backend and the frontend.
Whenever your frontend application interacts with the backend, such as sending API requests, consuming responses, and handling errors, it should have type safety around all these operations.
Similarly, if your backend can interact with the frontend directly, like rendering Inertia pages, then this operation should also be type-safe.
Before we start
Let's start by defining the scope of this project.
The generated types rely on import references and will work when both the frontend and the backend are part of the same codebase.
We will not parse the entire codebase into an Abstract Syntax Tree, since doing that on every code change is expensive and will worsen the DX with changes propagating slowly. Instead, we will use AST-GREP to scan the codebase based on certain conventions.
The generated types or runtime values will be written within the
.adonisjs
directory within your project root. The types used by the frontend will go inside the.adonisjs/client
directory, and the types used by the server will go inside the.adonisjs/server
directory.We will not change how you write your AdonisJS applications, since we deeply care about the abstractions we have created over the years and want to continue using them.
The framework core will expose the APIs for scanning the codebase and extracting metadata from it, but it won't generate any types or runtime code.
Tuyau will use these low-level APIs and will bring e2e type-safety to your apps.
The same set of APIs used by Tuyau can be used by other 3rd party packages to add additional features that rely on a deeper understanding of the codebase.
Rendering Inertia pages
One of the core goals of end-to-end type safety is to ensure that the data passed from the backend to the frontend is fully typed and validated, even when using server-driven rendering with Inertia.
In AdonisJS, the first thing you typically do in an Inertia app is render pages written using a frontend framework like Vue or React. Each page receives a set of props from the backend, and defining the types for those props plays a central role in maintaining type safety.
In the following example, we define the
PostsIndex
page that accepts an array ofPost
.Note
In this example, the
Post
type is defined manually within the frontend component. This is a common approach today, though we'll explore ways to reduce duplication and better align types between frontend and backend in the sections that follow.Just like functions in TypeScript, a component should be free to define the types for its parameters, and it should be the job of the caller to provide the correct data at the time of rendering the component.
In our case, the caller is the
inertia.render
method.Therefore, the first thing we have to do is make the
inertia.render
method call type-safe. This will be done by inspecting all the components within theinertia/pages
directory and creating a centralized types file for it.Once we have the type information within the
InertiaPages
interface, we can make theinertia.render
method rely on it.Inertia page props
While defining prop types manually works, it introduces duplication between the controller and the frontend component. Ideally, the frontend should not have to redefine the shape of data that already exists on the backend.
To address this, AdonisJS offers the
InferPageProps
helper, which allows you to infer the props for a page directly from the return type of a controller method. However, this approach comes with some caveats.PostsController.index
method is rendering theposts/index.tsx
page, and therefore its props can be used as an input. But, there is no system enforcing it.InferPageProps
, but serializing rich data-types (like models) from the backend never leads to accurate types, hence a lot of manual type-casting is required.To solve these issues, we first have to decouple Inertia pages from a controller (or at least remove that illusion), because in reality, a page could be rendered from multiple controllers.
We will resume this discussion after talking about HTTP transformers.
Introducing HTTP transformers
To precisely know what types your backend will return, we need an additional layer of serialization that will convert rich data types like classes to JSON data types with precise type information.
Whenever the data is flowing between two systems, having great control and visibility of the data becomes super important. This is where we want you to use HTTP transformers for all Inertia responses.
Note
I will be using HTTP transformers with Lucid models. However, the transformers are not coupled to models and can be used with plain objects or any other JavaScript data types.
Creating transformers
Let's take an example of rendering the
PostsIndex
page. In this case, we must create a transformer for thePost
model.We have intentionally kept the API of transformers simple and not used any declarative API like DTOs. The imperative API of transformers allows you to prepare data at runtime with complete freedom of writing conditionals, running loops, injecting dependencies, and so on.
Using transformers
Now, within the controller, we can use this transformer to transform a collection of
Post
model instances.Accessing transformer types within the frontend codebase
We will generate types by scanning for all the transformers within your codebase and make them available as
Data
objects within the frontend codebase.You can update the
PostsIndex
page to use thePost
data-type as follows.Tip
All the non-page components in the frontend codebase can also rely on the
Data
objects for type information.Completing the full circle
Let's re-inspect the type-safety of rendering Inertia pages with this new approach.
Data
objects for its types without typing them manually.interia.render
calls are type-safe. Hence, the TypeScript compiler will scream if you provide the wrong data.Inertia forms
Let's move to forms and see how we can make form submissions type-safe. Here, we would want the list of available routes and the data they accept.
Generating types
This is done by scanning for all the routes that are using a controller for handling requests. Additionally, the controller's validators and return types are collected.
The following is a rough representation of a scanned route.
Since our scanners won't walk through the entire codebase, you will have to define the HTTP boundaries within the controller method itself.
The boundaries here are the validator usage and the controller's return value.
The
useForm
helperOnce we have all the routes and their validation schemas, we can use the
useForm
helper from Tuyau that will rely on the generated types to provide the needed type-safetyForms response type-safety
Since form submissions in Inertia always lead to server-side redirects, there is no need to manually process the response. Hence, form response type-safety is not even a concern with Inertia applications.
Link
component type-safetyThe
Link
component exported by Tuyau is type-safe. You may specify the routes and other attributes as follows.Router visits
You may use the
useRouter
import from Tuyau to make therouter.visit
method calls type-safe.Type-safe API client
Along with the Inertia helpers, Tuyau also comes with a type-safe API client for making fetch calls to your backend.
The API client is helpful for applications with a separate frontend. However, it can also be used with Inertia to make one-off API calls for advanced use cases.
For those who already know Tuyau, the philosophy remains the same, but we are making a few breaking changes:
The biggest one is that we will revamp the chainable API to use route names for chaining, instead of the route URI.
Currently, Tuyau does not throw errors for
4xx/5xx
status codes, unless you chain the$unwrap
method. We will be changing this behavior, and all non-success status codes will throw an Error.We are removing the
$
prefixes for special methods. Instead, we will mark those keywords as reserved, and they cannot be used to name routes on the backend.Chainable API
The new chainable API will be built around the route names and controllers rather than URLs. Here's a quick difference between the two.
Route definition
Existing API
The existing API uses the route URI as the properties for chaining, and you will use the
$post
,$get
, or$put
methods to make an API call for a given specific HTTP method.Also, you pass the route params for each segment independently by calling intermediate methods.
The new proposed API
The new proposed API is designed around the route names. You do not have to think in URIs or the HTTP methods anymore. Just use route names everywhere.
How about unnamed routes?
For unnamed routes, we will create the chainable API using the
controller + method
combination. For example:The
request
methodIf you are not a big fan of the chainable API, then you may use the
client.request
method with the route name to make an API request.Error handling
Currently, Tuyau does not throw an error for
4xx/5xx
status codes unless you chain the.unwrap()
method.However, we noticed that most users always use
unwrap
; therefore, we have decided to always throw an error for non-success status codes.Existing behavior
New proposed behavior
Making requests using URLs
Finally, we will expose
client.get/post/put/delete
methods to make fetch calls directly using URIs. While we do not recommend this, we still want to offer an API that is independent of naming routes and following certain conventions.URL builder
Alongside the API client, you will also have access to a URL builder that you may use to create URIs for known routes.
Request and response types
Currently, to get the types for the
request
andresponse
returned by an endpoint, you have to use theInferResponseType
andInferRequestType
helpers.However, we will be providing direct access to the types of endpoints either via the route names or the route paths.
Checking the current route
Inside the Inertia codebase, it could be handy to know if we are on a specific route or not. A typical use case is to highlight the active link in a navigation menu.
Tanstack Query integration
Tuyau will also provide Tanstack query integration based on the Query Options API.
For every nesting level of your route names, you will be able to access
pathKey()
, which returns a query key that allows you to invalidate all queries under a specific route "namespace".Same, you will be able to access every queryKey for a route name using the
queryKey()
method.You will also be able to make calls using URLs.
A few notes about the API client and the URL builder
client.request/get/post/put/delete
methods are reserved keywords. Therefore, you cannot name your routes to start withrequest
,get
,post
,put
, ordelete
.camelCase
. For example,/posts/:post_id
param will be accepted aspostId
.Beta Was this translation helpful? Give feedback.
All reactions