TypeScript Bookshelf - HeyItWorked/babel-shelf GitHub Wiki
Port: 8081 | Runtime: Bun | Framework: Hono | DB driver: pg (node-postgres)
TypeScript is JavaScript with static types bolted on. It compiles to JavaScript before running. Bun is an alternative runtime to Node.js — faster startup, built-in bundler, and native TypeScript execution (no compile step needed).
ts-bookshelf/
├── src/
│ ├── index.ts ← Entry point: connect DB, start server
│ ├── routes.ts ← Route mapping (separated for testability)
│ ├── models.ts ← Book interface + status types
│ ├── handlers.ts ← HTTP handlers (logic layer)
│ └── db.ts ← SQL queries (translator layer)
├── test/
│ ├── handlers.test.ts ← Integration tests (8 cases)
│ └── helpers.ts ← Test utilities
├── package.json ← Dependencies + scripts
├── tsconfig.json ← TypeScript compiler config
├── bun.lock ← Lock file
└── Dockerfile
TS convention: src/ for source, test/ for tests. Unlike Go where everything is flat.
// TypeScript — models.ts
export interface Book {
id: number;
title: string;
author: string;
status: BookStatus;
}
export type BookStatus = "want to read" | "reading" | "finished";# Python equivalent
from typing import Literal
from dataclasses import dataclass
BookStatus = Literal["want to read", "reading", "finished"]
@dataclass
class Book:
id: int
title: str
author: str
status: BookStatusWhat's different from Go: TypeScript's interface is structural — any object with the right shape matches. Go's struct is nominal — you must explicitly create a Book{}. TypeScript also gets union types ("reading" | "finished") which Go doesn't have at all.
// TypeScript — this works! Any object with these fields is a Book
const book: Book = { id: 1, title: "Dune", author: "Frank Herbert", status: "reading" }
// Go — you must explicitly construct a Book struct
book := Book{Id: 1, Title: "Dune", Author: "Frank Herbert", Status: "reading"}Every database call and HTTP handler is async:
// TypeScript — db.ts
export async function insertBook(book: Book): Promise<Book> {
const result = await pool.query<Book>(sql, [book.title, book.author, book.status])
return result.rows[0]
}# Python equivalent (asyncio)
async def insert_book(book: Book) -> Book:
result = await pool.fetch(sql, book.title, book.author, book.status)
return result[0]// Go equivalent — no async needed, blocking is fine
func insertBook(book Book) (Book, error) {
row := db.QueryRow(sql, book.Title, book.Author, book.Status)
// ...
}Why TS needs async but Go doesn't: JavaScript is single-threaded. Without await, a database query would freeze the entire server. Go uses goroutines — each request gets its own lightweight thread, so blocking calls are natural. Python sits in between — asyncio is opt-in.
Mental model: await means "go do this, let other requests run while I wait, come back when it's done." In Go, the runtime handles this automatically.
// TypeScript — db.ts
export let pool: Pool
export function setPool(p: Pool) { pool = p }# Python — you'd just do this:
pool: Pool | None = None
def set_pool(p: Pool):
global pool
pool = pWhy the setter? ES modules give you a read-only binding when you import. If index.ts does import { pool } from "./db" and then sets pool = new Pool(...), it only changes the local copy. The setter writes to the actual variable inside db.ts.
// This DOESN'T work:
import { pool } from "./db"
pool = new Pool(...) // Error: cannot assign to import
// This DOES work:
import { setPool } from "./db"
setPool(new Pool(...)) // calls function inside db.tsGo doesn't have this problem — package-level variables are directly mutable from any file in the same package.
Hono combines request and response into a single Context:
// TypeScript — handlers.ts
export async function createBook(c: Context): Promise<Response> {
const body = await c.req.json() // read from request
return c.json(book, 201) // write response
}# Python Flask equivalent
@app.post("/books")
def create_book():
body = request.get_json() # read from request
return jsonify(book), 201 # write response// Go — separate objects
func CreateBook(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&book) // read from r
respondJSON(w, 201, book) // write to w
}| Framework | Read from | Write to |
|---|---|---|
| Go stdlib | r *http.Request |
w http.ResponseWriter |
| Hono (TS) | c.req |
c.json(), c.text()
|
| Flask (Python) | flask.request |
return jsonify(...) |
// routes.ts — just the mapping
const app = new Hono()
app.post("/books", createBook)
app.get("/books", getBooks)
// ...
export default app
// index.ts — just the boot
export default { port: 8081, fetch: app.fetch }Why separate? Tests need to import the app without starting the server. If routes lived in index.ts, importing them would also trigger the database connection and listen().
# Python equivalent pattern (Flask)
# routes.py
from flask import Blueprint
bp = Blueprint('books', __name__)
@bp.post("/books")
def create_book(): ...
# app.py
from routes import bp
app = Flask(__name__)
app.register_blueprint(bp)
app.run(port=8081)Go doesn't need this separation — setupRouter() returns the handler without starting the server, so tests just call setupRouter() directly.
// TypeScript — db.ts
export async function getBookById(id: number): Promise<Book | undefined> {
const result = await pool.query<Book>(sql, [id])
return result.rows[0] // undefined if no rows
}
// TypeScript — handlers.ts
const book = await getBookById(id)
if (!book) {
return c.text("book not found", 404)
}# Python equivalent
def get_book_by_id(id: int) -> Book | None:
result = pool.fetchone(sql, id)
return result # None if no rows// Go — errors instead of null
func getBookById(id int) (Book, error) {
err := row.Scan(...)
if err != nil {
return Book{}, err // zero value, not null
}
}The difference: TypeScript uses undefined (the value doesn't exist). Go uses errors (the operation failed). Gleam will use Result(Book, error) (the operation succeeded or failed — you must handle both).
// index.ts
await pool.query("SELECT 1") // this works at the top level!
console.log("connected to database")In Node.js, you'd need to wrap this in an async function main(). Bun supports top-level await — you can use await at the module level. This makes the entry point cleaner.
# Python — you need asyncio.run() to use await at top level
import asyncio
async def main():
await pool.execute("SELECT 1")
asyncio.run(main())// index.ts
export default { port: 8081, fetch: app.fetch }This is Bun-specific magic. When a file's default export has port and fetch, Bun treats it as an HTTP server definition. No app.listen() needed.
# Python — no equivalent, you must explicitly start the server
if __name__ == "__main__":
app.run(port=8081)// TypeScript — clean and compact
if (!body.title || !body.author) {
return c.text("title and author are required", 400)
}
if (!body.status) {
body.status = STATUS_WANT_TO_READ
} else if (!VALID_STATUSES.includes(body.status)) {
return c.text("invalid status", 400)
}// Go — more explicit, same logic
if book.Title == "" || book.Author == "" {
http.Error(w, "title and author are required", http.StatusBadRequest)
return
}
switch book.Status {
case "":
book.Status = StatusWantToRead
case StatusWantToRead, StatusReading, StatusFinished:
// valid
default:
http.Error(w, "invalid status", http.StatusBadRequest)
return
}Observation: Go uses a switch with explicit valid cases. TS uses Array.includes(). Both achieve the same result, but Go's switch is exhaustive by convention (the default case), while TS's includes is a runtime check.
// TypeScript
return c.body(null, 204) // null body, 204 status// Go
w.WriteHeader(http.StatusNoContent) // just the status code, no body method needed# Python Flask
return '', 204| Feature | Node.js | Bun |
|---|---|---|
| TypeScript | Needs tsc or ts-node
|
Native, zero config |
| Test runner | Needs Jest/Vitest | Built-in (bun test) |
| Package install |
npm install (~10s) |
bun install (~1s) |
| Speed | V8 engine | JavaScriptCore (Safari's engine) |
| Lock file | package-lock.json |
bun.lock (binary, smaller) |
For this project, Bun means: no tsconfig build step, no test framework to install, and bun test just works.
// TypeScript — columns auto-map to object keys
const result = await pool.query<Book>("SELECT id, title, author, status FROM books")
// result.rows[0] = { id: 1, title: "Dune", author: "Frank Herbert", status: "reading" }// Go — must manually map each column
err := row.Scan(&book.Id, &book.Title, &book.Author, &book.Status)pg matches column names to JavaScript object keys automatically. Go's database/sql has no idea what your struct looks like — you must manually Scan each column into each field, in order.
Tradeoff: TS is more convenient but less explicit. If you rename a column in SQL, TS silently gets undefined. Go fails at Scan with a clear error.
Every Hono handler returns Promise<Response>:
-
Promisebecause the handler isasync(database calls needawait) -
Responseis the Web APIResponseobject (not Hono-specific)
Bun implements the standard Web API — same Request/Response classes as browsers and Deno. This means Hono apps are portable across runtimes.
// TypeScript — db.ts
if (result.rowCount === null || result.rowCount === 0) {
return false
}Why check null? The pg driver types rowCount as number | null. For DELETE queries it's always a number, but TypeScript's type system doesn't know that. The null check satisfies the compiler.
Go's equivalent (result.RowsAffected()) returns (int64, error) — never null, but might error.
import { pool } from "./db" gives you a read-only binding. You can read pool but can't reassign it. That's why setPool() exists.
Tests import setPool and connect in beforeAll. Because tests run sequentially by default in Bun, they share the same DB and can see each other's side effects. The TS tests handle this by capturing bookId from create and using it in later tests (unlike Go which hardcoded /books/1).
For 204 No Content, use c.body(null, 204). Using c.json() would set Content-Type: application/json and try to encode null, which isn't what you want.
The codebase uses Number(raw) to parse IDs. This converts "abc" to NaN (caught by isNaN). parseInt("123abc") would return 123 — silently ignoring trailing junk. Number() is stricter.