Quick Start
Let's build a simple Express Todos app with TypeScript and Butterfly, using SQLite for storage! The finished code can be found in the examples/simple-express
directory on GitHub.
For this project, we'll be using Pug for templating, because it's widely used and easy to read and write. Let's start by installing all of the dependencies we'll need:
npm i -D typescript @types/express
npm i express pug @aleksrutins/butterfly
Let's make a couple of scripts to make our life easier, and set "type": "module"
to add ES module support:
{
// ...
"type": "module",
// ...
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"db": "DATABASE_URL=sqlite3:data.db butterfly"
}
// ...
}
Now, let's initialize our database. Create a migrations
directory with a 0-create-todos.up.sql
file and a 0-create-todos.down.sql
file. A todo has an integer ID and some text for the content, so put the following in the .up.sql
file:
create table todos (
id integer primary key autoincrement,
content text
)
All the .down.sql
file needs to do is drop the table:
drop table todos
Now, run the migration:
npm run db up
Now, let's start actually writing some code! Open a new file, and save it as src/index.ts
. Let's start by importing our packages and creating our Express server:
import { connect } from '@aleksrutins/butterfly';
import express from 'express';
const app = express();
We'll talk more about connect
in a bit.
Now, let's tell TypeScript about the format of a todo:
type Todo = {
id: number,
content: string
}
If you're using JavaScript, you don't need to declare your table types again outside of your migrations, but it's useful to make TypeScript happy.
Now, connect to the database and make sure it's connected:
const db = await connect('sqlite3:data.db');
if(!db) throw "Failed to connect to database";
connect
will take a URI, detect which driver you need (see the list of supported databases to see which protocols map to which drivers), and connect to the database, returning the driver instance. In this case, we're using SQLite3 with a file in the current directory called data.db
, so we just pass sqlite3:data.db
.
Hook up Pug and a static file server:
app.set('view engine', 'pug');
app.use(express.static('static'));
Now, we reach the interesting part: let's make the homepage. Since this is a quick start for Butterfly, not for Pug or CSS, you can copy-paste the template and styles:
doctype html
html(lang='en')
head
meta(name='viewport', content='width=device-width, initial-scale=1.0')
title Todos
link(rel='stylesheet', href='/app.css')
body
main
h1 Todos
ul
if todos.length == 0
li.no-todos You're all caught up!
else
each todo in todos
li
span= todo.content
form(action='/delete', method='post')
input(type="hidden", name="id", value=todo.id)
button(type="submit").delete-btn Delete
form(action="/create", method="post")#create-form
input(type="text", name="content", placeholder="What do you need to do?", autofocus)
button(type="submit")#add-btn Add
html, body {
font-family: -apple-system, Cantarell, sans-serif;
margin: 0;
padding: 0;
background-color: rgb(209, 209, 209);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
height: 100vh;
}
main {
display: block;
background-color: white;
border-radius: 20px;
padding: 25px;
}
h1 {
text-align: center;
}
ul {
border-radius: 20px;
background-color: rgb(241, 241, 241);
list-style-type: none;
padding: 10px;
}
button {
border-radius: 5px;
padding: 10px 15px;
border: none;
cursor: pointer;
}
li:not(.no-todos) {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 2px;
}
li.no-todos {
text-align: center;
font-style: italic;
}
li span {
margin-right: 5px;
}
li span, li form {
display: block;
}
li button.delete-btn {
background-color: red;
color: white;
}
form#create-form {
display: flex;
flex-direction: row;
justify-content: stretch;
}
form#create-form > * {
display: block;
}
form#create-form > input {
flex-grow: 1;
}
button#add-btn {
background-color: green;
color: white;
margin-left: 3px;
}
input[type=text] {
background-color: white;
border: 1px solid rgb(201, 201, 201);
border-radius: 5px;
padding: 10px;
}
Now, if you're still here after all that CSS, let's get on to the logic! First, though, read this blurb:
There are two ways to query your database with Butterfly: you can either use the query<T>
(returning a value) or exec
(not returning a value) methods, or you can use the q<T>
and e
template tags. To explain the difference, I'll give an example; the two statements below do the exact same thing:
db.exec('INSERT INTO todos (content) VALUES (?)', [myContent]);
db.e`INSERT INTO todos (content) VALUES (${myContent})`;
There are three main advantages to using q
and e
over query
and exec
:
- They're shorter and easier to read.
- They protect from SQL injection without you, the programmer, having to worry about it.
- They're database-independent — for instance, although SQLite uses
?
and PostgreSQL uses$<number>
for parameters, you can use the sameq
ore
call for either, while you would have to use two separatequery
orexec
calls.
Because of those, his tutorial will use q
and e
, but you're free to use whichever fits your usecase best.
The /
handler is relatively short:
app.get('/', async (_req, res) => {
const todos = await db.q<Todo>`SELECT * FROM todos`;
res.render('index', { todos });
});
One important thing to note, however, is that the type parameter for q<T>
is not an array! Both query
and q
will always return an array, so there's no need to specify it in the type parameter.
Other than that, the handler should be relatively self-explanatory. (If it isn't, please make an issue on GitHub and tell me about it!)
The /create
and /delete
handlers are equally simple, although they use e
instead of q
:
app.post('/create', express.urlencoded({ extended: false }), async (req, res) => {
await db.e`INSERT INTO todos (content) VALUES (${req.body.content})`;
res.redirect('/');
});
app.post('/delete', express.urlencoded({ extended: false }), async (req, res) => {
await db.e`DELETE FROM todos WHERE id = ${req.body.id}`;
res.redirect('/');
})
As explained above, q
and e
will automatically turn an interpolated string into safe query parameters, so there's no need to worry about SQL injection.
Now, all we have to is run the server and close the database when the program exits:
const port = process.env['PORT'] ?? 8080;
app.listen(port, () => console.log(`Listening on ${port}`));
process.on('exit', db.destroy);
And we're done! Now, build and run:
npm run build
npm start
... and see your beautiful new todo app!
I'm not great at writing tutorials, so please submit issues if you encountered any difficulties!