Skip to content

Elysia Tutorial

We will be building a small CRUD note-taking API server.

There's no database or other "production ready" features. This tutorial is going to only focus on Elysia feature and how to use Elysia only.

We expected it to take around 15-20 minutes if you follow along.

Setup

Elysia is built on Bun, an alterantive runtime to Node.js.

Install Bun if you haven't already.

bash
curl -fsSL https://bun.sh/install | bash
bash
powershell -c "irm bun.sh/install.ps1 | iex"

Create a new project

bash
# Create a new product
bun create elysia hi-elysia

# cd into the project
cd hi-elysia

# Install dependencies
bun install

This will create a barebone project with Elysia and basic TypeScript config.

Start the development server

bash
bun dev

Open your browser and go to http://localhost:3000, you should see Hello Elysia message on the screen.

Elysia use Bun with --watch flag to automatically reload the server when you make changes.

Route

To add a new route, we specify an HTTP method, a pathname, and a value.

Let's start by opening the src/index.ts file as follows:

typescript
import { Elysia } from 'elysia'

const app = new Elysia()
    .get('/', () => 'Hello Elysia')
    .get('/hello', 'Do you miss me?') 
    .listen(3000)

Open http://localhost:3000/hello, you should see Do you miss me?.

There are several HTTP methods we can use, but we will use the following for this tutorial:

  • get
  • post
  • put
  • patch
  • delete

Other methods are available, use the same syntax as get

typescript
import { Elysia } from 'elysia'

const app = new Elysia()
    .get('/', () => 'Hello Elysia')
    .get('/hello', 'Do you miss me?') 
    .post('/hello', 'Do you miss me?') 
    .listen(3000)

Elysia accept both value and function as a response.

However, we can use function to access Context (route and instance information).

typescript
import { 
Elysia
} from 'elysia'
const
app
= new
Elysia
()
.
get
('/', () => 'Hello Elysia')
.
get
('/', ({
path
}) =>
path
)
.
post
('/hello', 'Do you miss me?')
.
listen
(3000)

Swagger

Entering a URL to the browser can only interact with the GET method. To interact with other methods, we need a REST Client like Postman or Insomnia.

Luckily, Elysia comes with a OpenAPI Schema with Scalar to interact with our API.

bash
# Install the Swagger plugin
bun add @elysiajs/swagger

Then apply the plugin to the Elysia instance.

typescript
import { Elysia } from 'elysia'
import { swagger } from '@elysiajs/swagger'

const app = new Elysia()
    // Apply the swagger plugin
    .use(swagger()) 
    .get('/', ({ path }) => path)
    .post('/hello', 'Do you miss me?')
    .listen(3000)

Navigate to http://localhost:3000/swagger, you should see the documentation like this: Scalar Documentation landing

Now we can interact with all the routes we have created.

Scroll to /hello and click a blue Test Request button to show the form.

We can see the result by clicking the black Send button. Scalar Documentation landing

Decorate

However, for more complex data we may want to use class for complex data as it's allow us to define custom methods and properties.

Now, let's create a singleton class to store our notes.

typescript
import { 
Elysia
} from 'elysia'
import {
swagger
} from '@elysiajs/swagger'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} const
app
= new
Elysia
()
.
use
(
swagger
())
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
listen
(3000)

decorate allow us to inject a singleton class into the Elysia instance, allowing us to access it in the route handler.

Open http://localhost:3000/note, we should see ["Moonhalo"] on the screen.

For Scalar documentation, we may need to reload the page to see the new changes. Scalar Documentation landing

Path parameter

Now let's retrieve a note by its index.

We can define a path parameter by prefixing it with a colon.

typescript
import { 
Elysia
} from 'elysia'
import {
swagger
} from '@elysiajs/swagger'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} const
app
= new
Elysia
()
.
use
(
swagger
())
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
get
('/note/:index', ({
note
,
params
: {
index
} }) => {
return
note
.
data
[index]
Element implicitly has an 'any' type because index expression is not of type 'number'.
}) .
listen
(3000)

Let's ignore the error for now.

Open http://localhost:3000/note/0, we should see Moonhalo on the screen.

Path parameter allow us to retrieve a specific part from the URL. In our case, we retrieve a "0" from /note/0 put into a variable named index.

Validation

The error above is a warning that path parameter can be any string, while an array index should be a number.

For example, /note/0 is valid, but /note/zero is not.

We can enforce and validate type by declaring a schema:

typescript
import { 
Elysia
,
t
} from 'elysia'
import {
swagger
} from '@elysiajs/swagger'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} const
app
= new
Elysia
()
.
use
(
swagger
())
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
get
(
'/note/:index', ({
note
,
params
: {
index
} }) => {
return
note
.
data
[
index
]
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
listen
(3000)

We import t from Elysia to and define a schema for the path parameter.

Now, if we try to access http://localhost:3000/note/abc, we should see an error message.

This code resolve the error we have seen earlier because of TypeScript warning.

Elysia schema doesn't not only enforce validation on the runtime, but it also infers a TypeScript type for auto-completion and checking error ahead of time, and a Scalar documentation.

Most frameworks only provide only one of these features or provided them separately requiring us to update each one separately, but Elysia provides all of them as a Single Source of Truth.

Validation type

Elysia provide validation for the following properties:

  • params - path parameter
  • query - URL querystring
  • body - request body
  • headers - request headers
  • cookie - cookie
  • response - response body

All of them sharing the same syntax as the example above.

Status code

By default, Elysia will return a status code of 200 for all routes even if the response is an error.

For example, if we try to access http://localhost:3000/note/1, we should see undefined on the screen which shouldn't be a 200 status code (OK).

We can change the status code by returning an error

typescript
import { 
Elysia
,
t
} from 'elysia'
import {
swagger
} from '@elysiajs/swagger'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} const
app
= new
Elysia
()
.
use
(
swagger
())
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
get
(
'/note/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404)
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
listen
(3000)

Now, if we try to access http://localhost:3000/note/1, we should see Not Found on the screen with a status code of 404.

We can also return a custom message by passing a string to the error function.

typescript
import { 
Elysia
,
t
} from 'elysia'
import {
swagger
} from '@elysiajs/swagger'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} const
app
= new
Elysia
()
.
use
(
swagger
())
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
get
(
'/note/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404, 'oh no :(')
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
listen
(3000)

Plugin

The main instance is starting to get crowded, we can move the route handler to a separate file and import it as a plugin.

Create a new file named note.ts:

typescript
import { 
Elysia
,
t
} from 'elysia'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} export const
note
= new
Elysia
()
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
get
(
'/note/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404, 'oh no :(')
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } )

Then on the index.ts, apply note into the main instance:

typescript
import { 
Elysia
,
t
} from 'elysia'
import {
swagger
} from '@elysiajs/swagger'
import {
note
} from './note'
class
Note
{
constructor(public
data
: string[] = ['Moonhalo']) {}
} const
app
= new
Elysia
()
.
use
(
swagger
())
.
use
(
note
)
.
decorate
('note', new
Note
())
.
get
('/note', ({
note
}) =>
note
.
data
)
.
get
(
'/note/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404, 'oh no :(')
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
listen
(3000)

Open http://localhost:3000/note/1 and you should see oh no :( as same as before.

We have just created a note plugin, by declaring a new Elysia instance.

Each plugin is a separate instance of Elysia which has its own routes, middlewares, and decorators which can be applied to other instances.

Applying CRUD

We can apply the same pattern to create, update, and delete routes.

typescript
import { Elysia, t } from 'elysia'

class Note {
    constructor(public data: string[] = ['Moonhalo']) {}

    add(note: string) { 
        this.data.push(note) 

        return this.data 
    } 

    remove(index: number) { 
        return this.data.splice(index, 1) 
    } 

    update(index: number, note: string) { 
        return (this.data[index] = note) 
    } 
}

export const note = new Elysia()
    .decorate('note', new Note())
    .get('/note', ({ note }) => note.data)
    .put('/note', ({ note, body: { data } }) => note.add(data), { 
        body: t.Object({ 
            data: t.String() 
        }) 
    }) 
    .get(
        '/note/:index',
        ({ note, params: { index }, error }) => {
            return note.data[index] ?? error(404, 'Not Found :(')
        },
        {
            params: t.Object({
                index: t.Number()
            })
        }
    )
    .delete( 
        '/note/:index', 
        ({ note, params: { index }, error }) => { 
            if (index in note.data) return note.remove(index) 

            return error(422) 
        }, 
        { 
            params: t.Object({ 
                index: t.Number() 
            }) 
        } 
    ) 
    .patch( 
        '/note/:index', 
        ({ note, params: { index }, body: { data }, error }) => { 
            if (index in note.data) return note.update(index, data) 

            return error(422) 
        }, 
        { 
            params: t.Object({ 
                index: t.Number() 
            }), 
            body: t.Object({ 
                data: t.String() 
            }) 
        } 
    ) 

Now let's open http://localhost:3000/swagger and try playing around with CRUD operation.

Group

If we look closely, all of the routes in note plugin all share a /note prefix.

We can simplify this by declaring prefix

typescript
export const 
note
= new
Elysia
({
prefix
: '/note' })
.
decorate
('note', new
Note
())
.
group
('/note', (
app
) =>
app
.
get
('/', ({
note
}) =>
note
.
data
)
.
put
('/', ({
note
,
body
: {
data
} }) =>
note
.
add
(
data
), {
body
:
t
.
Object
({
data
:
t
.
String
()
}) }) .
get
(
'/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404, 'Not Found :(')
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
delete
(
'/:index', ({
note
,
params
: {
index
},
error
}) => {
if (
index
in
note
.
data
) return
note
.
remove
(
index
)
return
error
(422)
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
patch
(
'/:index', ({
note
,
params
: {
index
},
body
: {
data
},
error
}) => {
if (
index
in
note
.
data
) return
note
.
update
(
index
,
data
)
return
error
(422)
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}),
body
:
t
.
Object
({
data
:
t
.
String
()
}) } ) )

Guard

Now we may notice that there are several routes in plugin that has params validation.

We may define a guard to apply validation to routes in the plugin.

typescript
export const 
note
= new
Elysia
({
prefix
: '/note' })
.
decorate
('note', new
Note
())
.
get
('/', ({
note
}) =>
note
.
data
)
.
put
('/', ({
note
,
body
: {
data
} }) =>
note
.
add
(
data
), {
body
:
t
.
Object
({
data
:
t
.
String
()
}) }) .
guard
({
params
:
t
.
Object
({
index
:
t
.
Number
()
}) }) .
get
(
'/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404, 'Not Found :(')
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
delete
(
'/:index', ({
note
,
params
: {
index
},
error
}) => {
if (
index
in
note
.
data
) return
note
.
remove
(
index
)
return
error
(422)
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}) } ) .
patch
(
'/:index', ({
note
,
params
: {
index
},
body
: {
data
},
error
}) => {
if (
index
in
note
.
data
) return
note
.
update
(
index
,
data
)
return
error
(422)
}, {
params
:
t
.
Object
({
index
:
t
.
Number
()
}),
body
:
t
.
Object
({
data
:
t
.
String
()
}) } )

Validation will applied to all routes after guard is called and tie to the plugin.

Lifecycle

Now in real-world usage, we may want to do something like logging before the request is processed.

Instead of inline console.log for each route, we may apply lifecycle that intercept request before/after it is processed.

There are several lifecycle that we can use, but in this case we will be using onTransform.

typescript
export const 
note
= new
Elysia
({
prefix
: '/note' })
.
decorate
('note', new
Note
())
.
onTransform
(function
log
({
body
,
params
,
path
,
request
: {
method
} }) {
console
.
log
(`${
method
} ${
path
}`, {
body
,
params
}) }) .
get
('/', ({
note
}) =>
note
.
data
)
.
put
('/', ({
note
,
body
: {
data
} }) =>
note
.
add
(
data
), {
body
:
t
.
Object
({
data
:
t
.
String
()
}) }) .
guard
({
params
:
t
.
Object
({
index
:
t
.
Number
()
}) }) .
get
('/:index', ({
note
,
params
: {
index
},
error
}) => {
return
note
.
data
[
index
] ??
error
(404, 'Not Found :(')
}) .
delete
('/:index', ({
note
,
params
: {
index
},
error
}) => {
if (
index
in
note
.
data
) return
note
.
remove
(
index
)
return
error
(422)
}) .
patch
(
'/:index', ({
note
,
params
: {
index
},
body
: {
data
},
error
}) => {
if (
index
in
note
.
data
) return
note
.
update
(
index
,
data
)
return
error
(422)
}, {
body
:
t
.
Object
({
data
:
t
.
String
()
}) } )

onTransform is called after routing but before validation, so we can do something like logging the request that is defined without 404 Not found route.

This allow us to log the request before it is processed, and we can see the request body and path parameters.

Scope

By default, lifecycle hook is encapsulated. Hook is applied to routes in the same instance, and is not applied to other plugins (routes that not defined in the same plugin).

This means onTransform log will not be called on other instance, unless we explcity defined as scoped or global.

Authentication

Now we may want to add authorization to our routes, so only owner of the note can update or delete the note.

Let's create a user.ts file that will handle the user authentication:

typescript
import { 
Elysia
,
t
} from 'elysia'
export const
user
= new
Elysia
({
prefix
: '/user' })
.
state
({
user
: {} as
Record
<string, string>,
session
: {} as
Record
<number, string>
}) .
put
(
'/sign-up', async ({
body
: {
username
,
password
},
store
,
error
}) => {
if (
store
.
user
[
username
])
return
error
(400, {
success
: false,
message
: 'User already exists'
})
store
.
user
[
username
] = await
Bun
.
password
.
hash
(
password
)
return {
success
: true,
message
: 'User created'
} }, {
body
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}) } ) .
post
(
'/sign-in', async ({
store
: {
user
,
session
},
error
,
body
: {
username
,
password
},
cookie
: {
token
}
}) => { if ( !
user
[
username
] ||
!(await
Bun
.
password
.
verify
(
password
,
user
[
username
]))
) return
error
(400, {
success
: false,
message
: 'Invalid username or password'
}) const
key
=
crypto
.
getRandomValues
(new
Uint32Array
(1))[0]
session
[
key
] =
username
token
.
value
=
key
return {
success
: true,
message
: `Signed in as ${
username
}`
} }, {
body
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}),
cookie
:
t
.
Cookie
(
{
token
:
t
.
Number
()
}, {
secrets
: 'seia'
} ) } )

Now there are a lot to unwrap here:

  1. We create a new instance with 2 routes for sign up and sign in.
  2. In the instance, we define an in-memory store user and session
    • 2.1 user will hold key-value of username and password
    • 2.2 session will hold a key-value of session and username
  3. In /sign-in we insert a username and hashed password with argon2id
  4. In /sign-up we does the following:
    • 4.1 We check if user exists and verify the password
    • 4.2 If the password matches, then we generate a new session into session
    • 4.3 We set cookie token with the value of session
    • 4.4 We append secret to cookie to add hash attacker from tampering with the cookie

TIP

As we are using an in-memory store, the data are wipe out every reload or everytime we edit the code.

We will fix that in the later part of the tutorial.

Now if we want to check if user is signed in, we could check for value of token cookie and check with the `session store.

Reference Model

However, we can recognize that both /sign-in and /sign-up both share same body model.

Instead of copy-pasting the model all over the place, we could use a reference model to reuse the model by specifying a name.

To create a reference model, we may use .model and pass the name and the value of models:

typescript
import { 
Elysia
,
t
} from 'elysia'
export const
user
= new
Elysia
({
prefix
: '/user' })
.
state
({
user
: {} as
Record
<string, string>,
session
: {} as
Record
<number, string>
}) .
model
({
signIn
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}),
session
:
t
.
Cookie
(
{
token
:
t
.
Number
()
}, {
secrets
: 'seia'
} ) }) .
model
((
model
) => ({
...
model
,
optionalSession
:
t
.
Optional
(
model
.
session
)
})) .
put
(
'/sign-up', async ({
body
: {
username
,
password
},
store
,
error
}) => {
if (
store
.
user
[
username
])
return
error
(400, {
success
: false,
message
: 'User already exists'
})
store
.
user
[
username
] = await
Bun
.
password
.
hash
(
password
)
return {
success
: true,
message
: 'User created'
} }, {
body
: 'signIn'
} ) .
post
(
'/sign-in', async ({
store
: {
user
,
session
},
error
,
body
: {
username
,
password
},
cookie
: {
token
}
}) => { if ( !
user
[
username
] ||
!(await
Bun
.
password
.
verify
(
password
,
user
[
username
]))
) return
error
(400, {
success
: false,
message
: 'Invalid username or password'
}) const
key
=
crypto
.
getRandomValues
(new
Uint32Array
(1))[0]
session
[
key
] =
username
token
.
value
=
key
return {
success
: true,
message
: `Signed in as ${
username
}`
} }, {
body
: 'signIn',
cookie
: 'session',
} )

After adding a model/models, we can reuse them by referencing their name in the schema instead of providing a literal type while providing the same functionality and type safety.

We may also notice that, there's a remap model performing in this line:

ts
import { Elysia } from 'elysia'

new Elysia()
	.model({
    	signIn: t.Object({
    		username: t.String({ minLength: 1 }),
    		password: t.String({ minLength: 8 })
    	}),
     	session: t.Cookie(
      		{
        		token: t.Number()
        	},
         	{
          		secrets: 'seia'
          	}
	   	)
    })
    .model((model) => ({ 
    	...model, 
     	optionalSession: t.Optional(model.session) 
    })) 

Elysia.model could accepts multiple overload:

  1. Providing an object, the register all key-value as models
  2. Providing a function, then access all previous models then return new models

By providing a function, we could do a remap/reference or filter out model we don't want to use.

However in our case we want to reference a model and create a new model from it. Notice that we create a new optionalSession model by referencing a model.session and wrap t.Optional over it.

The rest parameter ...rest is also important as we want to keep all the model while adding a new one.

Finally, we could add the /profile and /sign-out route as follows:

typescript
import { 
Elysia
,
t
} from 'elysia'
export const
user
= new
Elysia
({
prefix
: '/user' })
.
state
({
user
: {} as
Record
<string, string>,
session
: {} as
Record
<number, string>
}) .
model
({
signIn
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}),
session
:
t
.
Cookie
(
{
token
:
t
.
Number
()
}, {
secrets
: 'seia'
} ) }) .
model
((
model
) => ({
...
model
,
optionalSession
:
t
.
Optional
(
model
.
session
)
})) .
put
(
'/sign-up', async ({
body
: {
username
,
password
},
store
,
error
}) => {
if (
store
.
user
[
username
])
return
error
(400, {
success
: false,
message
: 'User already exists'
})
store
.
user
[
username
] = await
Bun
.
password
.
hash
(
password
)
return {
success
: true,
message
: 'User created'
} }, {
body
: 'signIn'
} ) .
post
(
'/sign-in', async ({
store
: {
user
,
session
},
error
,
body
: {
username
,
password
},
cookie
: {
token
}
}) => { if ( !
user
[
username
] ||
!(await
Bun
.
password
.
verify
(
password
,
user
[
username
]))
) return
error
(400, {
success
: false,
message
: 'Invalid username or password'
}) const
key
=
crypto
.
getRandomValues
(new
Uint32Array
(1))[0]
session
[
key
] =
username
token
.
value
=
key
return {
success
: true,
message
: `Signed in as ${
username
}`
} }, {
body
: 'signIn',
cookie
: 'optionalSession'
} ) .
get
(
'/sign-out', ({
cookie
: {
token
} }) => {
token
.
remove
()
return {
success
: true,
message
: 'Signed out'
} }, {
cookie
: 'optionalSession'
} ) .
get
(
'/profile', ({
cookie
: {
token
},
store
: {
user
,
session
},
error
}) => {
const
username
=
session
[
token
.
value
]
if (!
username
)
return
error
(401, {
success
: false,
message
: 'Unauthorized'
}) return {
success
: true,
username
} }, {
cookie
: 'session'
} )

As we are going to apply authorization in the note, we are going to need to repeat 2 things:

  1. Checking if user exists
  2. Getting user id (in our case 'username')

For 1. instead of using guard, we could use a macro.

Plugin deduplication

As we are going to reuse this hook in multiple modules (user, and note), let's extract the service (utility) part out and apply to both modules.

ts
import { 
Elysia
,
t
} from 'elysia'
export const
userService
= new
Elysia
({
name
: 'user/service' })
.
state
({
user
: {} as
Record
<string, string>,
session
: {} as
Record
<number, string>
}) .
model
({
signIn
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}),
session
:
t
.
Cookie
(
{
token
:
t
.
Number
()
}, {
secrets
: 'seia'
} ) }) .
model
((
model
) => ({
...
model
,
optionalSession
:
t
.
Optional
(
model
.
session
)
})) export const
user
= new
Elysia
({
prefix
: '/user' })
.
use
(
userService
)
.
state
({
user
: {} as
Record
<string, string>,
session
: {} as
Record
<number, string>
}) .
model
({
signIn
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}),
session
:
t
.
Cookie
(
{
token
:
t
.
Number
()
}, {
secrets
: 'seia'
} ) }) .
model
((
model
) => ({
...
model
,
optionalSession
:
t
.
Optional
(
model
.
session
)
}))

The name property here is very important, as it's a unique identifier for the plugin to prevent duplicate instance (like a singleton).

If we were to define the instance without the plugin, hook/lifecycle and routes and going to be register every time the plugin is used.

Our intention is to apply this plugin (service) to multiple modules to provide utility function, this make deduplication very important as life-cycle shouldn't be register twice.

Macro

Macro allows us to define a custom hook with custom life-cycle management.

To define a macro, we could use .macro as the follows:

ts
import { 
Elysia
,
t
} from 'elysia'
export const
userService
= new
Elysia
({
name
: 'user/service' })
.
state
({
user
: {} as
Record
<string, string>,
session
: {} as
Record
<number, string>
}) .
model
({
signIn
:
t
.
Object
({
username
:
t
.
String
({
minLength
: 1 }),
password
:
t
.
String
({
minLength
: 8 })
}),
session
:
t
.
Cookie
(
{
token
:
t
.
Number
()
}, {
secrets
: 'seia'
} ) }) .
model
((
model
) => ({
...
model
,
optionalSession
:
t
.
Optional
(
model
.
session
)
})) .
macro
(({
onBeforeHandle
}) => ({
isSignIn
(
enabled
: true) {
if (!
enabled
) return
onBeforeHandle
(
({
error
,
cookie
: {
token
},
store
: {
session
} }) => {
if (!
token
.
value
)
return
error
(401, {
success
: false,
message
: 'Unauthorized'
}) const
username
=
session
[
token
.
value
as unknown as number]
if (!
username
)
return
error
(401, {
success
: false,
message
: 'Unauthorized'
}) } ) } }))

We have just create a new macro name isSignIn that accept boolean value, if it was true, then we add a onBeforeHandle event that execute after validation but before the main handler, allowing us to extract authentication logic here.

To use the macro, simply specified isSignIn: true as follows:

ts
    .
get
(
'/profile', ({
cookie
: {
token
},
store
: {
user
,
session
},
error
}) => {
const
username
=
session
[
token
.
value
]
if (!
username
)
return
error
(401, {
success
: false,
message
: 'Unauthorized'
}) return {
success
: true,
username
} }, {
isSignIn
: true,
cookie
: 'session'
} )

As we specified isSignIn, we can extract the imperative checking part, and reuse the same logic on multiple routes without copy-pasting the same code all over again.

TIP

This may seems like a small code change to trade for a larger boilerplate, but as the server grow complex, the user-checking could also grows to be a very complex mechanism as well.

Resolve

Our last objective is to get the username (id) from token, we could use resolve to define a new property into context same as store but only execute per request.

Unlike decorate and store, resolve is defined at beforeHandle stage or the value will be available after validation.

This ensure that the property like cookie: 'session' is exists before creating a new property.

ts
export const 
getUserId
= new
Elysia
()
.
use
(
userService
)
.
guard
({
cookie
: 'session'
}) .
resolve
(({
store
: {
session
},
cookie
: {
token
} }) => ({
username
:
session
[
token
.
value
]
}))

In this instance, we define a new property username by using resolve, allowing us to reduce the getting username logic into a property instead.

We don't define a name in this getUserId instance because we want guard and resolve to reapply into multiple instance.

TIP

Same as macro, resolve plays well if the logic for getting the property is complex and might not worth for a small operation like this. But since in the real-world we are going to need database-connection, caching, and queing might make it fits the narrative.

Scope

Now if we try to apply the use the getUserId, we might notice that the property username and guard isn't applied.

ts
export const 
getUserId
= new
Elysia
()
.
use
(
userService
)
.
guard
({
isSignIn