Tutorial#
Install typera with yarn or npm.
For Express:
yarn add typera-express
# or
npm install typera-express
For Koa:
yarn add typera-koa
# or
npm install typera-koa
Here's an example of a typed route handler that updates a user's profile in the database:
// Change 'typera-express' to 'typera-koa' if you're using Koa
import { Parser, Response, Route, URL, route } from 'typera-express'
import * as t from 'io-ts'
interface User {
id: number
name: string
age: number
}
// Decodes an object { name: string, age: number }
const userBody = t.type({ name: t.string, age: t.number })
const updateUser: Route<
Response.Ok<User> | Response.NotFound | Response.BadRequest<string>
> = route
.put('/user/:id(int)') // Capture id from the path
.use(Parser.body(userBody)) // Use the userBody decoder for the request body
.handler(async (request) => {
// This imaginary function takes the user id and data, and updates the
// user in the database. If the user does not exist, it returns null.
const user = await updateUserInDatabase(
request.routeParams.id,
request.body
)
if (user === null) {
return Response.notFound()
}
return Response.ok({
id: user.id,
name: user.name,
age: user.age,
})
})
Let's go through it in detail.
// Change 'typera-express' to 'typera-koa' if you're using Koa
import { Parser, Response, Route, URL, route } from 'typera-express'
interface User {
id: number
name: string
age: number
}
// Decodes an object { name: string, age: number }
const userBody = t.type({ name: t.string, age: t.number })
We first import the stuff that is needed, and define an object type that is returned from the route handler. We also define an io-ts codec for decoding incoming user data.
const updateUser: Route<
Response.Ok<User> | Response.NotFound | Response.BadRequest<string>
> = route /* ... */
Then we declare our route's possible responses: 200 OK
with User
as a body,
404 Not Found
, or 400 Bad Request
with a string
body.
The types in the Response
namespace correspond to HTTP status codes, and their
type parameter denotes the type of the response body. All the standard statuses
are covered, and you can also have custom ones like this:
Response.Response<418, string>
You don't need to provide the response types because typera will infer them for you. But annotating helps you catch bugs if you accidentally change the result data of a route. By annotating your route with what you actually wanted to return, you let the compiler notice if reality doesn't match the expectations.
route
.put('/user/:id(int)') // Capture id from the path
.use(Parser.body(userBody)) // Use the userBody decoder for the request body
.handler(async (request) => {
/* ... */
})
Here we tell that our route is going to handle PUT
requests. The argument of
route.put()
is the path pattern. Parts of the path can be captured, like
:id(int)
above (more on that later).
The .use()
method adds a middleware to the route. The userBody
io-ts codec
was defined above, and passing it to the Parser.body()
middleware instructs
typera to parse the incoming request body with that codec.
The .handler()
method adds the actual route logic, and here is where the magic
happens. The function passed to .handler()
gets as an argument the request
object, that will contain all path captures as well as all the results of the
middleware you passed. And what's great is that the data is correctly typed!
In our example, request
will have the following inferred type:
interface MyRequest {
routeParams: {
// These are the path captures, `:id(int)` in this case
id: number
}
body: {
// This is the output of the userBody decoder
name: string
age: number
}
// With typera-express
req: express.Request
res: express.Response
// With typera-koa
//ctx: koa.Context
}
(In reality the type won't be exatly as above, but a bit more complex intersection type instead. In any case, it can be used as if it was like above, editor autocomplete will work correctly, etc.)
The last part is the actual route logic:
route
.put(/*...*/)
.use(/*...*/)
.handler(async (request) => {
// This imaginary function takes the user id and data, and updates the
// user in the database. If the user does not exist, it returns null.
const user = await updateUserInDatabase(
request.routeParams.id,
request.body
)
if (user === null) {
return Response.notFound()
}
return Response.ok({
id: user.id,
name: user.name,
age: user.age,
})
})
The above code returns either 200 OK
with the user data in the body, or
404 Not Found
without any body, depending on whether the user was found in the
database or not.
Note that the OK response body corresponds to the User
type we defined
earlier. We annotated the route handler to return a User
body with the OK
response.
Let's assume we made a typo in our code and wrote ic
instead of id
:
return Response.ok({ ic: user.id, name: user.name, age: user.age })
// OOPS! -------------^
The TypeScript compiler catches this and gives you an error. Likewise, if in the
future someone changes the updateUserFromDatabase
function and e.g. adds a new
field to the user data that the function takes as the second argument, the code
won't compile before they also fix the userBody
decoder to match the new type.
It's not required to use the response helpers like Response.ok()
or
Response.notFound()
. You can also return plain objects:
return { status: 200, body: { ... }, headers: { ... } }
Did you notice that the updateUser
route handler also had a
Response.BadRequest<string>
as a possible response even though the route logic
never returns such a response? This is because the validation of the request
body can fail. The Parser.body()
middleware produces a 400 Bad Request
response if the request body doesn't pass validation, and this response type is
included as one of the possible response types of the route.
There's one piece still missing: adding our route handlers to the app. Use the
router()
function to create a router from a bunch of routes, and the
.handler()
method of the router to get a handler that can be added to the app.
Here's an example for Express:
import * as express from 'express'
import * as bodyParser from 'body-parser'
import { router } from 'typera-express'
const app = express()
// body-parser is needed if you use Parser.body()
app.use(bodyParser.json())
app.use(router(updateUser /*, otherRoute, stillAnother */).handler())
And for Koa:
import * as Koa from 'koa'
import * as bodyParser from 'koa-bodyparser'
import { router } from 'typera-koa'
const app = new Koa()
// koa-bodyparser is needed if you use Parser.body()
app.use(bodyParser())
app.use(router(updateUser /*, otherRoute, stillAnother */).handler())