localhost
GET
The point of creating an API server is to take an input and process it.
JavaScript allow any data to be any type. Elysia provide a tool to validate data out of to box to ensure that the data is in the correct format.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/id/:id', ({ params: { id } }) => id, {
params: t.Object({
id: t.Number()
})
})
.listen(3000)
Elysia.t, a schema builder based on TypeBox that provide type-safety on both runtime, compile-time, and OpenAPI schema for generating OpenAPI/Swagger documentation.
TypeBox is a very fast, lightweight, and type-safe runtime validation library for TypeScript. Elysia extends and customize the default behavior of TypeBox to match for server-side validation.
We believe that an integration like this should take care of the framework by default instead of relying on the user end to set up a custom type on every project.
Elysia supports declarative schema with the following types:
These properties should be provided as the third argument of the route handler to validate the incoming request.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/id/:id', () => 'Hello World!', {
query: t.Object({
name: t.String()
}),
params: t.Object({
id: t.Number()
})
})
.listen(3000)
GET
The response should as follows:
URL | Query | Params |
---|---|---|
/id/a | ❌ | ❌ |
/id/1?name=Elysia | ✅ | ✅ |
/id/1?alias=Elysia | ❌ | ✅ |
/id/a?name=Elysia | ✅ | ❌ |
/id/a?alias=Elysia | ❌ | ❌ |
When schema is provided, the type will be inferred from the schema automatically, and generate an OpenAPI type for Swagger documentation generation, leaving out the redundant task of providing type manually.
An incoming HTTP Message is the data sent to the server. It can be in the form of JSON, form-data, or any other format.
import { Elysia, t } from 'elysia'
new Elysia()
.post('/body', ({ body }) => body, {
body: t.Object({
name: t.String()
})
})
.listen(3000)
The validation should be as follows:
Body | Validation |
---|---|
{ name: 'Elysia' } | ✅ |
{ name: 1 } | ❌ |
{ alias: 'Elysia' } | ❌ |
undefined | ❌ |
Elysia disabled body-parser for GET and HEAD message by default, following the specs of HTTP/1.1 RFC2616
If the request method does not include defined semantics for an entity-body, then the message-body SHOULD be ignored when handling the request.
Most browsers disable the attachment of the body by default for GET and HEAD method.
Validate an incoming HTTP Message (or body).
These messages are additional messages for the webserver to process.
The body is provided as same as body
in fetch
API. The content type should be set accordingly to the defined body.
fetch('https://elysiajs.com', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Elysia'
})
})
File is a special type of body that can be used to upload files.
import { Elysia, t } from 'elysia'
new Elysia()
.post('/body', ({ body }) => body, {
body: t.Object({
file: t.File(),
multipleFiles: t.Files()
})
})
.listen(3000)
By providing a file type, Elysia will automatically assume that content-type is multipart/form-data
.
Query is the data sent through the URL. It can be in the form of ?key=value
.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/query', ({ query }) => query, {
query: t.Object({
name: t.String()
})
})
.listen(3000)
Query must be provided in the form of an object.
The validation should be as follows:
Query | Validation |
---|---|
/?name=Elysia | ✅ |
/?name=1 | ✅ |
/?alias=Elysia | ❌ |
/?name=ElysiaJS&alias=Elysia | ✅ |
/ | ❌ |
A query string is a part of the URL that starts with ? and can contain one or more query parameters, which are key-value pairs used to convey additional information to the server, usually for customized behavior like filter or search.
Query is provided after the ? in Fetch API.
fetch('https://elysiajs.com/?name=Elysia')
When specifying query parameters, it's crucial to understand that all query parameter values must be represented as strings. This is due to how they are encoded and appended to the URL.
Params or path parameters are the data sent through the URL path.
It can be in the form of /key
.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/id/:id', ({ params }) => params, {
params: t.Object({
id: t.Number()
})
})
GET
Params must be provided in the form of an object.
The validation should be as follows:
URL | Validation |
---|---|
/id/1 | ✅ |
/id/a | ❌ |
Path parameter (not to confused with querystring or query parameter).
This field is usually not needed as Elysia can infer types from path parameters automatically, unless a need for specific value pattern is need, for example numeric value or template literal pattern.
fetch('https://elysiajs.com/id/1')
If params schema is not provided, Elysia will automatically infer type as string automatically.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/id/:id', ({ params }) => params)
Header is the data sent through the request's header.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/headers', ({ headers }) => headers, {
headers: t.Object({
authorization: t.String()
})
})
Unlike other types, headers has additionalProperties
set to true
by default.
This means that headers can have any key-value pair, but the value must match the schema.
HTTP headers let the client and the server pass additional information with an HTTP request or response, usually treated as metadata.
This field is usually used to enforce some specific header field, for example, Authorization
.
Headers are provided as same as body
in fetch
API.
fetch('https://elysiajs.com/', {
headers: {
authorization: 'Bearer 12345'
}
})
TIP
Elysia will parse headers as a lower-case key only.
Please make sure that you are using a lower-case field name when using header validation.
Cookie is the data sent through the request's cookie.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/cookie', ({ cookie }) => cookie, {
cookie: t.Cookie({
cookieName: t.String()
})
})
Cookie must be provided in the form of t.Cookie
or t.Object
.
Same as headers
, header has additionalProperties
set to true
by default.
An HTTP cookie is a small piece of data that a server sends to the client, it's data that is sent with every visit to the same web server to let the server remember client information.
In simpler terms, a stringified state that sent with every request.
This field is usually used to enforce some specific cookie field.
A cookie is a special header field that Fetch API doesn't accept a custom value but is managed by the browser. To send a cookie, you must use a credentials
field instead:
fetch('https://elysiajs.com/', {
credentials: 'include'
})
t.Cookie
is a special type that is equivalent to t.Object
but allow to set cookie specific options.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/cookie', ({ cookie }) => cookie.name.value, {
cookie: t.Cookie({
name: t.String()
}, {
secure: true,
httpOnly: true
})
})
Response is the data returned from the handler.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/response', () => {
return {
name: 'Jane Doe'
}
}, {
response: t.Object({
name: t.String()
})
})
Response can be set per status code.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/response', ({ error }) => {
if (Math.random() > 0.5)
return error(400, {
error: 'Something went wrong'
})
return {
name: 'Jane Doe'
}
}, {
response: {
200: t.Object({
name: t.String()
}),
400: t.Object({
error: t.String()
})
}
})
To make a field optional, we may use t.Optional
.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/optional', ({ query }) => query, {
query: t.Optional(
t.Object({
name: t.String()
})
)
})
This is an Elysia specific feature, allowing us to make a field optional.
Guard can be used to apply schema to multiple handlers.
import { Elysia, t } from 'elysia'
new Elysia()
.get('/none', ({ query }) => 'hi')
.guard({
query: t.Object({
name: t.String()
})
})
.get('/query', ({ query }) => query)
.listen(3000)
This code ensures that the query must have name with a string value for every handler after it. The response should be listed as follows:
GET
The response should be listed as follows:
Path | Response |
---|---|
/none | hi |
/none?name=a | hi |
/query | error |
/query?name=a | a |
If multiple global schemas are defined for same property, the latest one will have the preference. If both local and global schemas are defined, the local one will have the preference.
You can use the Elysia constructor to set the behavior for unknown fields on outgoing and incoming bodies via the normalize
option. By default, elysia will raise an error in case a request or response contains fields which are not explicitly allowed in the schema of the respective handler.
You can change this by setting normalize
to true when constructing your elysia instance.
import { Elysia, t } from 'elysia'
new Elysia({
normalize: true
})
TypeBox API is designed around and similar to TypeScript type.
There are a lot of familiar names and behaviors that intersect with TypeScript counter-parts like: String, Number, Boolean, and Object as well as more advanced features like Intersect, KeyOf, Tuple for versatility.
If you are familiar with TypeScript, creating a TypeBox schema has the same behavior as writing a TypeScript type except it provides an actual type validation in runtime.
To create your first schema, import Elysia.t
from Elysia and start with the most basic type:
import { Elysia, t } from 'elysia'
new Elysia()
.post('/', ({ body }) => `Hello ${body}`, {
body: t.String()
})
.listen(3000)
This code tells Elysia to validate an incoming HTTP body, make sure that the body is String, and if it is String, then allow it to flow through the request pipeline and handler.
If the shape doesn't match, then it will throw an error, into Error Life Cycle.
TypeBox provides a basic primitive type with the same behavior as same as TypeScript type.
The following table lists the most common basic type:
TypeBox | TypeScript |
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
Elysia extends all types from TypeBox allowing you to reference most of the API from TypeBox to use in Elysia.
See TypeBox's Type for additional types that are supported by TypeBox.
TypeBox can accept an argument for more comprehensive behavior based on JSON Schema 7 specification.
TypeBox | TypeScript |
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
typescript
| typescript
|
See JSON Schema 7 specification For more explanation for each attribute.
The following are common patterns that are often found useful when creating a schema.
Allow multiple types via union.
TypeBox | TypeScript | Value |
typescript
| typescript
|
|
Provided in a property of t.Object
, allowing the field to be undefined or optional.
TypeBox | TypeScript | Value |
typescript
| typescript
| typescript
|
Allowing all of the fields in t.Object
to be optional.
TypeBox | TypeScript | Value |
typescript
| typescript
| typescript
|
TypeBox offers an additional "error" property, allowing us to return a custom error message if the field is invalid.
TypeBox | Error |
typescript
|
|
typescript
|
|
Elysia.t
is based on TypeBox with pre-configuration for usage on the server while providing additional types commonly found on server-side validation.
You can find all of the source code of Elysia type in elysia/type-system
.
The following are types provided by Elysia:
Numeric accepts a numeric string or number and then transforms the value into a number.
t.Numeric()
This is useful when an incoming value is a numeric string for example path parameter or query string.
Numeric accepts the same attribute as Numeric Instance
TIP
This is not need as Elysia type already transform Number to Numeric automatically
A singular file. Often useful for file upload validation.
t.File()
File extends attribute of base schema, with additional property as follows:
A format of the file like image, video, audio.
If an array is provided, will attempt to validate if any of the format is valid.
type?: MaybeArray<string>
Minimum size of the file.
Accept number in byte or suffix of file unit:
minSize?: number | `${number}${'k' | 'm'}`
Maximum size of the file.
Accept number in byte or suffix of file unit:
maxSize?: number | `${number}${'k' | 'm'}`
The following are the specifications of the file unit: m: MegaByte (1048576 byte) k: KiloByte (1024 byte)
Extends from File, but adds support for an array of files in a single field.
t.Files()
File extends attributes of base schema, array, and File.
Object-like representation of a Cookie Jar extended from Object type.
t.Cookie({
name: t.String()
})
Cookie extends attributes of Object and Cookie with additional properties follows:
The secret key for signing cookies.
Accepts a string or an array of string
secrets?: string | string[]
If an array is provided, Key Rotation will be used, the newly signed value will use the first secret as the key.
Allow the value to be null but not undefined.
t.Nullable(t.String())
Allow the value to be null and undefined.
t.MaybeEmpty(t.String())
For additional information, you can find the full source code of the type system in elysia/type-system
.
There are 2 ways to provide a custom error message when the validation fails:
error
propertyElysia's offers an additional "error" property, allowing us to return a custom error message if the field is invalid.
import { Elysia, t } from 'elysia'
new Elysia()
.post('/', () => 'Hello World!', {
body: t.Object(
{
x: t.Number()
},
{
error: 'x must be a number'
}
)
})
.listen(3000)
The following is an example of usage of the error property on various types:
TypeBox | Error |
typescript
|
|
typescript
|
|
typescript
|
|
typescript
|
|
Over a string, Elysia type's error can also accepts a function to programatically return custom error for each property.
The error function accepts same argument as same as ValidationError
import { Elysia, t } from 'elysia'
new Elysia()
.post('/', () => 'Hello World!', {
body: t.Object({
x: t.Number({
error() {
return 'Expected x to be a number'
}
})
})
})
.listen(3000)
TIP
Hover over the error
to see the type
Please be cautious that the error function will only be called if the field is invalid.
Please consider the following table:
Code | Body | Error |
typescript
| json
| Expected x to be a number |
typescript
| json
| (default error, `t.Number.error` is not called) |
typescript
| json
| Expected value to be an object |
We can customize the behavior of validation based on onError event by narrowing down the error code call "VALIDATION".
import { Elysia, t } from 'elysia'
new Elysia()
.onError(({ code, error }) => {
if (code === 'VALIDATION')
return error.message
})
.listen(3000)
Narrowed down error type, will be typed as ValidationError
imported from 'elysia/error'.
ValidationError exposed a property name validator typed as TypeCheck, allowing us to interact with TypeBox functionality out of the box.
import { Elysia, t } from 'elysia'
new Elysia()
.onError(({ code, error }) => {
if (code === 'VALIDATION')
return error.validator.Errors(error.value).First().message
})
.listen(3000)
ValidationError provides a method ValidatorError.all
, allowing us to list all of the error causes.
import { Elysia, t } from 'elysia'
new Elysia()
.post('/', ({ body }) => body, {
body: t.Object({
name: t.String(),
age: t.Number()
}),
error({ code, error }) {
switch (code) {
case 'VALIDATION':
console.log(error.all)
// Find a specific error name (path is OpenAPI Schema compliance)
const name = error.all.find(
(x) => x.summary && x.path === '/name'
)
// If has a validation error, then log it
if(name)
console.log(name)
}
}
})
.listen(3000)
For more information about TypeBox's validator, see TypeCheck.
Sometimes you might find yourself declaring duplicated models, or re-using the same model multiple times.
With reference model, we can name our model and reuse them by referencing with name.
Let's start with a simple scenario.
Suppose we have a controller that handles sign-in with the same model.
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/sign-in', ({ body }) => body, {
body: t.Object({
username: t.String(),
password: t.String()
}),
response: t.Object({
username: t.String(),
password: t.String()
})
})
We can refactor the code by extracting the model as a variable, and reference them.
import { Elysia, t } from 'elysia'
// Maybe in a different file eg. models.ts
const SignDTO = t.Object({
username: t.String(),
password: t.String()
})
const app = new Elysia()
.post('/sign-in', ({ body }) => body, {
body: SignDTO,
response: SignDTO
})
This method of separating the concerns is an effective approach but we might find ourselves reusing multiple models with different controllers as the app gets more complex.
We can resolve that by creating a "reference model" allowing us to name the model and use auto-completion to reference it directly in schema
by registering the models with model
.
import { Elysia, t } from 'elysia'
const app = new Elysia()
.model({
sign: t.Object({
username: t.String(),
password: t.String()
})
})
.post('/sign-in', ({ body }) => body, {
// with auto-completion for existing model name
body: 'sign',
response: 'sign'
})
When we want to access the model's group, we can separate a model
into a plugin which when registered will provide a set of models instead of multiple import.
// auth.model.ts
import { Elysia, t } from 'elysia'
export const authModel = new Elysia()
.model({
sign: t.Object({
username: t.String(),
password: t.String()
})
})
Then in an instance file:
// index.ts
import { Elysia } from 'elysia'
import { authModel } from './auth.model'
const app = new Elysia()
.use(authModel)
.post('/sign-in', ({ body }) => body, {
// with auto-completion for existing model name
body: 'sign',
response: 'sign'
})
This not only allows us to separate the concerns but also allows us to reuse the model in multiple places while reporting the model into Swagger documentation.
model
accepts an object with the key as a model name and value as the model definition, multiple models are supported by default.
// auth.model.ts
import { Elysia, t } from 'elysia'
export const authModel = new Elysia()
.model({
number: t.Number(),
sign: t.Object({
username: t.String(),
password: t.String()
})
})
Duplicated model names will cause Elysia to throw an error. To prevent declaring duplicate model names, we can use the following naming convention.
Let's say that we have all models stored at models/<name>.ts
, and declare the prefix of the model as a namespace.
import { Elysia, t } from 'elysia'
// admin.model.ts
export const adminModels = new Elysia()
.model({
'admin.auth': t.Object({
username: t.String(),
password: t.String()
})
})
// user.model.ts
export const userModels = new Elysia()
.model({
'user.auth': t.Object({
username: t.String(),
password: t.String()
})
})
This can prevent naming duplication at some level, but in the end, it's best to let the naming convention decision up to your team's agreement is the best option.
Elysia provides an opinionated option for you to decide to prevent decision fatigue.