TypeScript Bookshelf - HeyItWorked/babel-shelf GitHub Wiki

TypeScript Bookshelf

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).


Project Structure

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.


Key Concepts — With Python Translations

1. Interfaces vs Types (TypeScript's Shape System)

// 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: BookStatus

What'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"}

2. async/await (Promises Under the Hood)

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.

3. The Module Export/Import System (and the setPool Pattern)

// 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 = p

Why 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.ts

Go doesn't have this problem — package-level variables are directly mutable from any file in the same package.

4. Hono's Context Object (c)

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(...)

5. Routes Separated from Entry Point

// 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.

6. Type Assertions and null Handling

// 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).

7. Bun's Top-Level Await

// 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())

8. Bun's export default Server Pattern

// 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)

TS vs Go — Side by Side on the Same Logic

Validation (CreateBook)

// 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.

DELETE response

// 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

Adjacent Learning: Things Worth Knowing

Why Bun Over Node?

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.

pg Auto-Mapping (vs Go's Manual Scan)

// 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.

The Promise<Response> Return Type

Every Hono handler returns Promise<Response>:

  • Promise because the handler is async (database calls need await)
  • Response is the Web API Response object (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.

result.rowCount Nullability

// 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.


Common Gotchas (From This Codebase)

1. ES Module Import Binding

import { pool } from "./db" gives you a read-only binding. You can read pool but can't reassign it. That's why setPool() exists.

2. Shared DB State Across Tests

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).

3. c.body(null, 204) Not c.json(null, 204)

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.

4. Number(raw) vs parseInt(raw)

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.

⚠️ **GitHub.com Fallback** ⚠️