REST API Crash Course - Full ElysiaJS CRUD API Beginner Tutorial
Timestamps
- - Intro
- - A look at Elysia
- - Video overview
- - What is bun?
- - Installation
- - Create a Repo
- - Creating a route or endpoint
- - Details Endpoint
- - Automatic documentation
- - Data validation
- - Working with a Database
- - Updating and deleting data
- - Delete Endpoint
- - Update with PUT
- - Errors
- - Object Relational Mappers (ORM)
- - Postgres and Docker installation
- - psql
- - Get Data with Drizzle
- - Insert with Drizzle
- - Deleting with Drizzle
- - Updating with Drizzle
- - Type Validation
- - Array Destructuring
- - Put vs Patch
- - What's next?
Full code for this video is on GitHub: https://github.com/CalebCurry/elysia
Elysia is a backend TypeScript framework for building APIs, and it runs on Bun instead of Node. Bun is a JavaScript runtime, so you can think of it as an alternative to Node.
What Elysia gives you out of the box:
- request validation built in, so the data you work with matches your rules at runtime AND is typed while you write the code
- type safety all the way through, including types shared between client and server
- first class OpenAPI documentation from the very first route
The documentation page specs out the whole API, so it's what you hand to whoever builds a frontend, a client library, or an MCP server against it (or an agent doing the same).
What we're building
An API for running marketing campaigns. A campaign has a name and a due date, and later on it could have content pieces hanging off of it (social posts, blogs, newsletters, whatever).
For this video it's just campaigns, and by the end we'll have full CRUD:
GET /api/v1/campaigns
GET /api/v1/campaigns/:id
POST /api/v1/campaigns
PUT /api/v1/campaigns/:id
DELETE /api/v1/campaigns/:id
We start on mock data, move to SQLite with raw SQL, then finish on Postgres through an ORM (Drizzle).
What is Bun
Bun is on v1.4 as I record this. The first 1.0 came out September 8th, 2023, so it's a few years old and past the point where it's a bunch of bugs. It's got almost 100,000 stars on GitHub and some pretty big users: Claude Code ships as a Bun single file executable, Midjourney runs notifications through Bun's WebSocket server, and Railway built its serverless functions on it. Bun is also part of Anthropic now, which I didn't know until I was researching this.
It runs TypeScript files directly, which takes a chunk of the usual TypeScript setup off your plate.
💡 Tip[!tip]
Bun strips the types and runs the JavaScript underneath, it does NOT typecheck. If you want type errors reported you still runtsc --noEmit(or lean on your editor).
Installation
Mac and Linux:
curl -fsSL https://bun.com/install | bash
Windows (PowerShell):
powershell -c "irm bun.sh/install.ps1|iex"
I keep all my projects in one folder, so make a directory for the project and scaffold in to it. The project is called omnicopy (copy as in marketing text, and it's supposed to be everywhere, I don't know, naming is hard):
mkdir omnicopy
cd omnicopy
bun create elysia .
The . means the current directory. If you'd rather it make the folder for you, give it the name instead: bun create elysia omnicopy.
Then run it:
bun run src/index.ts
If you get Failed to start server. Is port 3000 in use? something else is already on 3000. Kill it, or change the port in src/index.ts.
Open http://localhost:3000 and you get "Hello Elysia". The whole server is in src/index.ts:
import { Elysia } from "elysia";
const app = new Elysia().get("/", () => "Hello Elysia").listen(3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);
Change the string and refresh, and nothing happens. bun run doesn't watch for changes. Use the dev script instead:
bun dev
That runs bun run --watch src/index.ts, which comes from the dev script in package.json. Same idea as npm run dev in Node, it's just a convention.
Create a Repo
The scaffold already made a git repo and a .gitignore, so commit right away:
git add .
git commit -m "initial commit"
Then create a repo on GitHub and push to it.
The generated .gitignore is decent, but it's missing two things we're about to create: database files and a plain .env (it only has the scoped ones like .env.local). Add these at the top:
.env
*.db
A .env is where anything sensitive goes, like a database password, and the .gitignore is what keeps it out of the repo. In VS Code the file goes from green to gray once it's ignored, which is a quick way to check.
git add .
git commit -m "update .gitignore"
git push origin main
Creating a route or endpoint
Some vocabulary first. A resource is the thing you're working with, so campaigns. An endpoint is any one thing you can do with that resource: GET it, POST it, PUT it, DELETE it. They'll all share the same path, just with different HTTP methods.
We'll mock the data with an array first, so the API side can get built without a database in the way, then swap in the database after. At the top of src/index.ts:
const campaigns = [
{
campaignId: 1,
name: 'Summer Sale',
dueDate: new Date(),
createdAt: new Date(),
},
{
campaignId: 2,
name: 'Black Friday',
dueDate: new Date(),
createdAt: new Date(),
},
];
Every route is going to live under /api/v1, and instead of typing that on each one you pass a prefix to the constructor. I also put each chained method on its own line so I don't lose any of them, with .listen() always last:
const app = new Elysia({ prefix: '/api/v1' })
.get('/campaigns', () => {
return { data: campaigns };
})
.get('/', () => 'Hello Caleb!')
.listen(3000);
With the prefix, localhost:3000 is now a 404 and the hello route moved to http://localhost:3000/api/v1. The campaigns are at http://localhost:3000/api/v1/campaigns. Returning a plain object sends it back as JSON.
You could write { campaigns } (shorthand when the key and variable name match), but I wrap everything in data. It's the same on every endpoint, it works for one item or a list, and it leaves room for other attributes later, like pagination info.
Details Endpoint
Now one campaign by id. The :id in the path is a parameter, and it shows up on params:
.get('/campaigns/:id', ({ params: { id }, status }) => {
const campaign = campaigns.find((c) => c.campaignId == Number(id));
if (!campaign) {
return status(404);
}
return { data: campaign };
})
The handler gets one argument, the context, which describes the request. You could write (context) => context.params.id, the parentheses above are just destructuring that same object to grab params (and id out of it) plus status.
id comes in as a string, so it gets converted with Number() for now. Try /api/v1/campaigns/1, /2, and something that doesn't exist to see the 404.
Here's what else is on the context:
body,query,params,headers,cookie,storefor request dataredirect,status,setfor building the responserequest,server,pathfor the raw request info
More on the handler and context in the docs.
💡 Tip[!tip]
Coming from Express, the shape is different. Express gives you(req, res)and you callres.status(404).json(...). In Elysia you get one context object and you RETURN the response. A plain object goes out as JSON with a 200, andstatus()is how you pick a different code.
git add .
git commit -m "details api example"
Automatic documentation
Two routes is already enough to get a documentation page:
bun add @elysia/openapi
import openapi from '@elysia/openapi';
const app = new Elysia({ prefix: '/api/v1' })
.use(openapi())
Then go to http://localhost:3000/api/v1/openapi. It's under /api/v1 because of the prefix, not /openapi like the docs show.
You can send requests right from this page. Open the :id endpoint, give it 1, run it, and the response shows up below, so you don't have to stare at raw JSON in the browser. I've worked with other languages and frameworks where getting a spec like this built out took a lot of time even with a library helping, so this one is pretty nice.
💡 Tip[!tip]
You'll see a lot of older tutorials installing@elysiajs/swagger. The OpenAPI plugin replaced it. The plugins are also published under both the@elysia/and@elysiajs/scopes, and the docs use@elysia/now.
Data validation
This is checking data at RUNTIME. TypeScript only checks the code you write, it can't stop a user from sending you the wrong thing, so validation is what makes sure requests actually have the type and structure you expect.
Import t and give the route a third argument, an object describing what you expect:
import { Elysia, t } from 'elysia';
.get(
'/campaigns/:id',
({ params: { id }, status }) => {
const campaign = campaigns.find((c) => c.campaignId == id);
if (!campaign) {
return status(404);
}
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
},
)
The Number() cast is gone. The schema says id is a number, so Elysia converts it before your handler runs, and TypeScript knows it's a number too.
Now try /api/v1/campaigns/hello:
{
"type": "validation",
"on": "params",
"property": "/id",
"message": "Expected union value",
"summary": "Property 'id' should be one of: 'numeric', 'number'",
...
}
Without the schema that same request was just a 404 not found, which doesn't tell the caller anything. And if you go back to the docs page, the expected structure for id is described there now, so whoever uses the API isn't guessing.
t is a fork of TypeBox. If you already know Zod, or already have Zod schemas, Elysia supports those too through Standard Schema, you don't have to use t.
90% of what you'll write looks like this:
{
params: t.Object({ id: t.Number() }),
query: t.Object({ limit: t.Optional(t.Number()) }),
body: t.Object({
name: t.String(),
dueDate: t.Optional(t.String())
})
}
params is the URL, query is the stuff after the ?, and body is the POST or PUT payload. There's a lot more in the validation docs, but once you know what you're trying to allow it's easy to look up the exact type.
Working with a Database
I don't want to build every endpoint on the array and then go back and change all of them, so the database comes in now, before POST, PUT and DELETE exist. Bun has SQLite built in, so there's nothing to install:
import { Database } from 'bun:sqlite';
const db = new Database('campaigns.db');
db.run(`
CREATE TABLE IF NOT EXISTS campaigns (
campaign_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
due_date TEXT,
created_at TEXT NOT NULL
)
`);
This runs every time the server starts (which with bun dev is every save), and that's fine because of IF NOT EXISTS. Take that part out and you'll get this on the next save:
SQLiteError: table campaigns already exists
Columns are snake_case, the SQL convention. SQLite stores dates as text, and NOT NULL means required, so due_date is the only optional column.
Seeding
Seeding is putting starter data in the tables, usually for development (or something like an admin user in production). Only do it if the table is empty, otherwise you get a new row every restart:
if (!db.query('SELECT 1 FROM campaigns').get()) {
db.run(`INSERT INTO campaigns (name, created_at) VALUES (?, ?)`, [
'Cyber Monday',
new Date().toISOString(),
]);
}
SELECT 1 ... .get() gives you nothing back when there are no rows, so ! flips that to true and we insert. We don't name campaign_id (the database assigns it) or due_date (optional).
Those ? marks are placeholders. The values get passed in separately in the array, and SQLite binds them as data instead of pasting them in to the SQL string, which is how you avoid SQL injection. Never build a query by sticking user input in to the string.
Now delete (or comment out) the mock campaigns array and point the two GET routes at the database:
.get('/campaigns', () => {
return {
data: db.query('SELECT * FROM campaigns').all(),
};
})
.get(
'/campaigns/:id',
({ params: { id }, status }) => {
const campaign = db
.query('SELECT * FROM campaigns WHERE campaign_id = ?')
.get(id);
if (!campaign) {
return status(404);
}
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
},
)
.all() returns every row, .get() returns the first one (or null). Test both from the docs page and you get the Cyber Monday row back.
Aliases
Look at the JSON though, it says campaign_id and due_date. The API is leaking the database's snake_case. Not the end of the world, but the easy fix is an alias on each column with AS:
.get('/campaigns', () => {
return {
data: db
.query(
'SELECT campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt FROM campaigns',
)
.all(),
};
})
.get(
'/campaigns/:id',
({ params: { id }, status }) => {
const campaign = db
.query(
'SELECT campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt FROM campaigns WHERE campaign_id = ?',
)
.get(id);
if (!campaign) {
return status(404);
}
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
},
)
It's a bit of extra typing. An ORM can do this conversion for you automatically, which we get to later.
Updating and deleting data
Time to create data. POST goes on the same /campaigns path as the "get all" route, since you're adding to that collection:
.post(
'/campaigns',
({ body, status }) => {
const campaign = db
.query(
`
INSERT INTO campaigns (name, due_date, created_at)
VALUES (?, ?, ?)
RETURNING campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt
`,
)
.get(body.name, body.dueDate ?? null, new Date().toISOString());
return status(201, { data: campaign });
},
{
body: t.Object({
name: t.String(),
dueDate: t.Optional(t.String({ format: 'date' })),
}),
},
)
RETURNING hands back the row that was just created, including the id the database picked, and .get() is where the values for the three ? go. dueDate is optional so it defaults to null. We send back 201 Created with the new campaign.
The format: 'date' makes dueDate a calendar date, year-month-day like 2026-11-27. Send something else and you get:
Expected string to match 'date' format
createdAt is a full timestamp on purpose. It's logging when the row was made and we never touch it, while the due date is something the user sets.
Delete Endpoint
DELETE uses the same path as the details endpoint, and the same params schema:
.delete(
'/campaigns/:id',
({ params: { id }, status }) => {
const { changes } = db.run(
'DELETE FROM campaigns WHERE campaign_id = ?',
[id],
);
if (changes === 0) return status(404);
return status(204);
},
{ params: t.Object({ id: t.Number() }) },
)
db.run() gives back an object with changes, how many rows were affected. Zero means there was nothing to delete, so 404. Otherwise 204 No Content.
Delete id 2 from the docs page and you get a 204, run it again and it's a 404, and it's gone from the list.
💡 Tip[!tip]
In the video I first wrotereturn 204instead ofreturn status(204). That sends back a 200 with the literal number204as the body. Returning a number is just data,status()is what actually sets the code.
Update with PUT
Give this one a try yourself before looking. It's the same patterns as above, update the row and return the updated campaign:
.put(
'/campaigns/:id',
({ params: { id }, body, status }) => {
const campaign = db
.query(
`UPDATE campaigns SET name = ?, due_date = ? WHERE campaign_id = ?
RETURNING campaign_id AS campaignId, name, due_date AS dueDate, created_at AS createdAt`,
)
.get(body.name, body.dueDate ?? null, id);
if (!campaign) return status(404);
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
body: t.Object({
name: t.String(),
dueDate: t.Optional(t.String({ format: 'date' })),
}),
},
)
Grab an id from the list, PUT a new name (I renamed mine to "Black Friday Cyber Monday"), and the updated row comes back with the same id. The attributes get replaced but the id stays, so it's logically still the same campaign. created_at isn't in the SET, so that stays too.
git add .
git commit -m "fix delete status code and add put"
Errors
There's default error handling already. Send a POST with "name": 5 and you get a big verbose validation error. If you're the one building the frontend that's probably fine, it gives you all the details you need. For a user facing API you'll probably want to control it, and that's .onError(), chained right after .use(openapi()):
.onError(({ code, error, status }) => {
if (code === 'NOT_FOUND') return status(404, { error: 'Not Found' });
if (code === 'VALIDATION')
return status(422, { error: 'Bad request data' });
console.error(error);
return status(500, { error: 'Something went wrong, try again later' });
})
422 Unprocessable Content is the common code for invalid data. I wrap each message in an object with an error key rather than sending back a plain string, same reason as the data wrapper.
The last line is the catch-all. Anything that isn't a 404 or a validation problem gets logged on the server with console.error and the client gets a generic 500, so you're not leaking internals. To see it, put a throw new Error() in the PUT handler and send a valid request (an invalid one fails validation before your handler ever runs).
If you're happy with the default messages, you don't need any of this.
Object Relational Mappers (ORM)
An ORM (object relational mapper) maps the objects in your code to rows in the database, so you work with the database through code instead of writing the SQL yourself. There are pros and cons you can go research, but I'd generally recommend one unless you specifically need to hand write your SQL.
Prisma is a great option. We're going with Drizzle, which is probably the recommended option for Elysia (it has its own integration page in the Elysia docs). The reason is drizzle-typebox. Elysia's t is a fork of TypeBox, and drizzle-typebox turns a Drizzle table in to TypeBox schemas, so you get request validation straight from your table definition without describing the same shape twice.
It's hard to see why that matters until you do it, so let's install it:
bun add drizzle-orm drizzle-typebox
bun add -D drizzle-kit
drizzle-kit is the CLI that creates the tables, so it's a dev dependency.
We're moving to Postgres at the same time, so the file structure changes a bit:
omnicopy/
.env DATABASE_URL, gitignored
drizzle.config.ts settings for the drizzle-kit CLI
src/
schema.ts the table definitions
index.ts the server
drizzle.config.ts goes at the top level next to package.json. It tells the CLI where the schema is, turns on automatic snake_case conversion, and reads the connection string from .env:
// needed for drizzle kit (CLI)
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema.ts',
dialect: 'postgresql',
casing: 'snake_case',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
Bun loads .env automatically, so there's no dotenv package. For now just put a placeholder in .env, we'll fill in the real value once Postgres is running:
DATABASE_URL=
src/schema.ts is where the CREATE TABLE moves to, written as TypeScript:
import { pgTable, serial, text, date, timestamp } from 'drizzle-orm/pg-core';
export const campaigns = pgTable('campaigns', {
campaignId: serial().primaryKey(),
name: text().notNull(),
dueDate: date(),
createdAt: timestamp().notNull().defaultNow(),
});
The properties are camelCase now. With casing: 'snake_case' Drizzle converts campaignId to campaign_id going in to the database and back again coming out, so all those AS aliases go away.
dueDate is a date() with no notNull(), so it's optional. createdAt is a timestamp(), a point in time the database manages for you, and defaultNow() means Postgres fills it in when the row gets created so we stop passing new Date().toISOString() around.
Now in src/index.ts, swap the SQLite setup for a Drizzle connection. The new Database(...), the CREATE TABLE, and the seed block all get deleted, along with the bun:sqlite import:
import { drizzle } from 'drizzle-orm/bun-sql';
import * as schema from './schema';
import { campaigns } from './schema';
const db = drizzle(process.env.DATABASE_URL!, { schema, casing: 'snake_case' });
drizzle-orm/bun-sql uses Bun's built in Postgres client. The casing gets repeated here because the config file is only for the CLI, and your queries need to know about the conversion too. The ! tells TypeScript to assume the env variable has a value, otherwise it complains that it's string | undefined.
Then update the first route. Queries go over the network now, so the handler is async and the query gets an await:
.get('/campaigns', async () => {
return {
data: await db.select().from(campaigns),
};
})
Send a request and you get Something went wrong, try again later from our onError, because there's no Postgres database yet.
Postgres and Docker installation
You can install Postgres directly, but I like running it in a Docker container. It's by far the easiest way to set it up, and it's easy to have a separate one per project. You'll need Docker installed.
drizzle-kit also needs a Postgres driver of its own (your app uses Bun's client, the CLI doesn't), so add pg as a dev dependency:
bun add -D pg
Then start Postgres:
docker run --name campaigns-db -e POSTGRES_PASSWORD=devpassword -e POSTGRES_USER=omnicopy -e POSTGRES_DB=campaigns -p 5432:5432 -d postgres:18
--name campaigns-dbso you can refer to the container by name- the
-evariables create the user, password, and database the first time the container starts -p 5432:5432maps a port on your machine to Postgres inside the container-druns it detached so you get your terminal back
If you already have something on 5432 (I usually do), change only the FIRST number, like -p 5435:5432. The second one is the port inside the container and stays the same.
Check it's running:
docker ps
psql
You don't need Postgres installed on your machine to run SQL against it, there's psql inside the container:
docker exec -it campaigns-db psql -U omnicopy -d campaigns
SELECT 5 + 5;
That gives back 10. A couple of psql commands worth knowing: \dt lists the tables (there aren't any yet), and \q quits. You'll probably want to write these commands down somewhere.
Now fill in .env. Every piece of the connection string comes from the docker run flags, user, password, host, port, and database:
DATABASE_URL=postgresql://omnicopy:devpassword@localhost:5432/campaigns
Use whatever port you mapped (mine was 5435). Restart the server and try the request again. Now the error is:
relation "campaigns" does not exist
So it's working, it's just broken, if that makes sense. We connected, the database just doesn't have the table yet. I split the terminal so the server keeps running on one side, and on the other:
bunx drizzle-kit push
Reading config file '.../drizzle.config.ts'
Using 'pg' driver for database querying
[✓] Pulling schema from database...
[✓] Changes applied
Send the request again and you get {"data":[]}. The container is running, the app is connected through Drizzle, and the table exists.
push makes the database match your schema directly. That's fine while it's one table on your own machine. Once you have a real database that other people depend on you'd switch to migrations (drizzle-kit generate and drizzle-kit migrate), which write versioned SQL files you commit. There's more on that in the Drizzle docs.
Get Data with Drizzle
The details endpoint, async and await again, with eq for the WHERE:
import { eq } from 'drizzle-orm';
.get(
'/campaigns/:id',
async ({ params: { id }, status }) => {
const [campaign] = await db
.select()
.from(campaigns)
.where(eq(campaigns.campaignId, id));
if (!campaign) {
return status(404);
}
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
},
)
db.select() always gives back an array, even when you're looking up one id. That's what the [campaign] is for, more on it in a second.
Insert with Drizzle
.post(
'/campaigns',
async ({ body, status }) => {
const [campaign] = await db
.insert(campaigns)
.values(body)
.returning();
return status(201, { data: campaign });
},
{
body: t.Object({
name: t.String(),
dueDate: t.Optional(t.String({ format: 'date' })),
}),
},
)
.returning() does the same job as RETURNING in the raw SQL, hands you back the new row. Create a few from the docs page (I clicked send a few times, which is fine, now there's stuff to delete).
An empty string for dueDate gets rejected with Bad request data. Leave dueDate off completely and it works, since it's nullable, but "" isn't null and it isn't a valid date.
That error message isn't very helpful though. Our onError hides the details and doesn't log validation errors, which makes building harder. While you're developing you can just comment it out and get Elysia's full validation errors back:
// .onError(({ code, error, status }) => {
// if (code === 'NOT_FOUND') return status(404, { error: 'Not Found' });
// if (code === 'VALIDATION')
// return status(422, { error: 'Bad request data' });
// console.error(error);
// return status(500, { error: 'Something went wrong, try again later' });
// })
You could also switch it on only outside of development with an environment variable, but I don't want to get off topic.
Deleting with Drizzle
.delete(
'/campaigns/:id',
async ({ params: { id }, status }) => {
const deleted = await db
.delete(campaigns)
.where(eq(campaigns.campaignId, id))
.returning();
if (deleted.length === 0) return status(404);
return status(204);
},
{ params: t.Object({ id: t.Number() }) },
)
There's no changes count anymore. With .returning() you get an array of the deleted rows, so an empty array means nothing matched and it's a 404. Delete id 2, you get a 204, do it again and it's a 404.
Updating with Drizzle
.put(
'/campaigns/:id',
async ({ params: { id }, body, status }) => {
const [campaign] = await db
.update(campaigns)
.set(body)
.where(eq(campaigns.campaignId, id))
.returning();
if (!campaign) return status(404);
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
body: t.Object({
name: t.String(),
dueDate: t.Optional(t.String({ format: 'date' })),
}),
},
)
Update campaign 1 with a new name and it works. Notice the due date stayed though, even though we didn't send one. That's more like a PATCH, which we fix below.
Type Validation
Those t.Object bodies on POST and PUT describe the exact same shape as the table. createInsertSchema from drizzle-typebox builds that validation schema from the table instead:
import { createInsertSchema } from 'drizzle-typebox';
const _insertCampaign = createInsertSchema(campaigns, {
dueDate: (schema) => t.String({ ...schema, format: 'date' }),
});
const campaignBody = t.Omit(_insertCampaign, ['campaignId', 'createdAt']);
_insertCampaign is an in-between step (hence the underscore). It's everything you could insert in to the table, including campaignId and createdAt. The database owns those two, so t.Omit takes them out. Hover over campaignBody and it's name, required, and dueDate, optional.
💡 Tip[!tip]
The video usescreateInsertSchema(campaigns)with no second argument. That quietly drops theformat: 'date'check we had on the hand written body, because a Postgresdate()column just becomes a plain string schema. With the original version,"dueDate": "next tuesday"passes validation, hits Postgres, and the client gets a 500 (withonErrorcommented out, the rawFailed query: insert into "campaigns" ...SQL comes back in the response). The second argument puts the date format back, and the callback form keepsdueDateoptional and nullable. Without the callback, passingt.String(...)directly makesdueDaterequired.
💡 Tip[!tip]
Assign the schema to a variable like_insertCampaignrather than callingcreateInsertSchemainline in a route. The Elysia Drizzle docs call out that inlining it can send TypeScript in to an infinite type instantiation loop.
Now both routes use it:
{
body: campaignBody,
}
PUT keeps its params as well. Change a column in schema.ts and the validation, the types, and the docs page all follow.
Array Destructuring
Quick one I missed at first. Every select, insert, and update in Drizzle gives back an array, so a single row needs pulled out with array destructuring, const [campaign] = await ..., which grabs the first element. Without the brackets, campaign is an array. An empty array is still truthy, so the 404 check never fires and you'd send back { data: [] }.
Put vs Patch
A PUT is supposed to replace the whole resource, and a PATCH changes only the fields you send. .set(body) behaves like a PATCH: Drizzle skips keys that are undefined, so leaving dueDate out of the request left the old due date in place.
To make it a real PUT, set each column explicitly and default the due date to null:
.set({
name: body.name,
dueDate: body.dueDate ?? null,
})
Now a PUT without dueDate clears it, and a PUT with one sets it.
Here's the full src/index.ts at the end:
import openapi from '@elysia/openapi';
import { Elysia, t } from 'elysia';
import { drizzle } from 'drizzle-orm/bun-sql';
import { eq } from 'drizzle-orm';
import { campaigns } from './schema';
import * as schema from './schema';
import { createInsertSchema } from 'drizzle-typebox';
// needed for drizzle orm
const db = drizzle(process.env.DATABASE_URL!, { schema, casing: 'snake_case' });
const _insertCampaign = createInsertSchema(campaigns, {
dueDate: (schema) => t.String({ ...schema, format: 'date' }),
});
const campaignBody = t.Omit(_insertCampaign, ['campaignId', 'createdAt']);
const app = new Elysia({ prefix: '/api/v1' })
.use(openapi())
// .onError(({ code, error, status }) => {
// if (code === 'NOT_FOUND') return status(404, { error: 'Not Found' });
// if (code === 'VALIDATION')
// return status(422, { error: 'Bad request data' });
// console.error(error);
// return status(500, { error: 'Something went wrong, try again later' });
// })
.get('/campaigns', async () => {
return {
data: await db.select().from(campaigns),
};
})
.get(
'/campaigns/:id',
async ({ params: { id }, status }) => {
const [campaign] = await db
.select()
.from(campaigns)
.where(eq(campaigns.campaignId, id));
if (!campaign) {
return status(404);
}
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
},
)
.post(
'/campaigns',
async ({ body, status }) => {
const [campaign] = await db
.insert(campaigns)
.values(body)
.returning();
return status(201, { data: campaign });
},
{
body: campaignBody,
},
)
.delete(
'/campaigns/:id',
async ({ params: { id }, status }) => {
const deleted = await db
.delete(campaigns)
.where(eq(campaigns.campaignId, id))
.returning();
if (deleted.length === 0) return status(404);
return status(204);
},
{ params: t.Object({ id: t.Number() }) },
)
.put(
'/campaigns/:id',
async ({ params: { id }, body, status }) => {
const [campaign] = await db
.update(campaigns)
.set({
name: body.name,
dueDate: body.dueDate ?? null,
})
.where(eq(campaigns.campaignId, id))
.returning();
if (!campaign) return status(404);
return { data: campaign };
},
{
params: t.Object({ id: t.Number() }),
body: campaignBody,
},
)
.get('/', () => 'Hello Caleb!')
.listen(3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);
💡 Tip[!tip]
This matches the repo with two changes: thedueDatedate format fix from the Type Validation section, and the leftoverimport { Database } from 'bun:sqlite'removed, since nothing uses SQLite anymore.
What's next?
From here the next steps are database relationships (the content pieces that hang off a campaign), using this API from a frontend, and the approach Elysia recommends for that. Leave a comment on the video if you want that one. The Elysia docs are good and pretty short if you want to keep going on your own.