IntermediateTypeScript · Lesson 9 of 9

PostgreSQL with node-postgres

Connect with a pool, run parameterised queries with typed rows, and keep related changes atomic with transactions.

pg (node-postgres) is the standard PostgreSQL driver for Node.js. Create one Pool for the whole application: it opens a few connections and lends them to queries, which is much faster than connecting per request. Keep the connection string in an environment variable (DATABASE_URL), never in the code, and call pool.end() when a script finishes so Node can exit.

Always pass values as parameters (WHERE form = $1, then [form]). The values travel separately from the SQL text, so a name like ' OR '1'='1 is just text and SQL injection is impossible. pool.query<Student>(...) types the rows; TypeScript cannot check that the SQL really returns those columns, so keep queries and their row types close together. Note the conversions: integer arrives as a number, but bigint, numeric, count() and sum() arrive as strings unless you cast them in SQL.

A transaction groups statements so they all succeed or all fail. It must run on one connection: take a client with pool.connect(), run BEGIN, your statements and COMMIT, and on any error ROLLBACK. Always release() the client in finally. A small withTransaction helper keeps this correct everywhere, and CHECK constraints in the schema are your last line of defence: here a payment that would make the balance negative is rolled back completely.

TerminalShell
npm install pg
npm install --save-dev @types/pg
createdb school_api
psql school_api -f schema.sql
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/school_api"
npx tsx src/main.ts
schema.sqlSQL
CREATE TABLE students (
  id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name       text NOT NULL CHECK (length(trim(name)) >= 2),
  form       smallint NOT NULL CHECK (form BETWEEN 1 AND 6),
  balance    integer NOT NULL DEFAULT 0 CHECK (balance >= 0),   -- fees owed, whole shillings
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE payments (
  id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  student_id integer NOT NULL REFERENCES students(id),
  amount     integer NOT NULL CHECK (amount > 0),
  paid_at    timestamptz NOT NULL DEFAULT now()
);
src/db.tsTypeScript
import pg from "pg";

// One pool for the whole app: it keeps a few connections open and reuses them.
export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
});

// Run several statements on ONE connection, committing only if all succeed.
export async function withTransaction<T>(work: (client: pg.PoolClient) => Promise<T>): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const result = await work(client);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();                     // always give the connection back to the pool
  }
}
src/students.tsTypeScript
import { pool, withTransaction } from "./db";

export interface Student {
  id: number;
  name: string;
  form: number;
  balance: number;
}

// $1, $2 ... are parameters: the values travel separately from the SQL, so no SQL injection.
export async function createStudent(name: string, form: number, balance = 0): Promise<Student> {
  const { rows } = await pool.query<Student>(
    "INSERT INTO students (name, form, balance) VALUES ($1, $2, $3) RETURNING id, name, form, balance",
    [name, form, balance],
  );
  return rows[0];
}

export async function findByForm(form: number): Promise<Student[]> {
  const { rows } = await pool.query<Student>(
    "SELECT id, name, form, balance FROM students WHERE form = $1 ORDER BY name",
    [form],
  );
  return rows;
}

export async function searchByName(text: string): Promise<Student[]> {
  const { rows } = await pool.query<Student>(
    "SELECT id, name, form, balance FROM students WHERE name ILIKE '%' || $1 || '%' ORDER BY name",
    [text],
  );
  return rows;
}

export async function findById(id: number): Promise<Student | undefined> {
  const { rows } = await pool.query<Student>("SELECT id, name, form, balance FROM students WHERE id = $1", [id]);
  return rows[0];
}

// Insert the payment and reduce the balance together, or not at all.
export async function recordPayment(studentId: number, amount: number): Promise<number> {
  return withTransaction(async (client) => {
    await client.query("INSERT INTO payments (student_id, amount) VALUES ($1, $2)", [studentId, amount]);
    const { rows } = await client.query<{ balance: number }>(
      "UPDATE students SET balance = balance - $2 WHERE id = $1 RETURNING balance",
      [studentId, amount],
    );
    if (rows.length === 0) throw new Error(`student ${studentId} not found`);
    return rows[0].balance;
  });
}
src/main.tsTypeScript
import { pool } from "./db";
import { createStudent, findById, findByForm, recordPayment, searchByName } from "./students";

const amina = await createStudent("Amina Hassan", 4, 150_000);
await createStudent("Juma Said", 4, 200_000);
console.log(await findByForm(4));

console.log("Balance after paying 50,000:", await recordPayment(amina.id, 50_000));

try {
  await recordPayment(amina.id, 500_000);           // would make the balance negative
} catch (err) {
  // The CHECK constraint failed, so the transaction rolled back the payment row too.
  console.log("Rejected:", (err as Error).message);
}
console.log("Still:", (await findById(amina.id))?.balance);

console.log(await searchByName("jum"));               // [ { id: 2, name: 'Juma Said', ... } ]
// Malicious input is just text when you use parameters: it matches no names.
console.log(await searchByName("' OR '1'='1"));        // []

await pool.end();                                     // let the process exit

Key points

  • One shared Pool; connection settings from DATABASE_URL.
  • Parameters ($1, $2) for every value: no string-building, no SQL injection.
  • Transactions on a single client with BEGIN/COMMIT/ROLLBACK and release() in finally.

Exercise

Add paymentSummary(form) (each student's number of payments, total paid and balance, as numbers, including students with no payments) and promoteForm(form, nextYearFee), which moves a form up one level and adds next year's fee to exactly those students, in one transaction. Show that promoting form 6 rolls back.

Show solution

Try the exercise yourself first — then compare your approach with this one.

count() and sum() return bigint/numeric, which pg returns as strings, so the query casts them with ::int to get numbers (safe here because the values are small). LEFT JOIN plus coalesce keeps students with no payments. promoteForm collects the ids it promoted with RETURNING id and charges exactly those, passing the JavaScript array as a PostgreSQL array (= ANY($1)); students already in the next form are untouched. Form 7 breaks the CHECK constraint, so the whole transaction rolls back.

src/reports.tsTypeScript
import { pool, withTransaction } from "./db";

export interface PaymentSummary {
  name: string;
  payments: number;
  totalPaid: number;
  balance: number;
}

// count() and sum() return bigint/numeric, which pg gives you as STRINGS
// (they can exceed JavaScript's safe integer range). Cast in SQL when the values are small.
export async function paymentSummary(form: number): Promise<PaymentSummary[]> {
  const { rows } = await pool.query<PaymentSummary>(
    `SELECT s.name,
            count(p.id)::int                 AS payments,
            coalesce(sum(p.amount), 0)::int  AS "totalPaid",
            s.balance
       FROM students s
       LEFT JOIN payments p ON p.student_id = s.id
      WHERE s.form = $1
      GROUP BY s.id
      ORDER BY s.name`,
    [form],
  );
  return rows;
}

// Move every student in a form up one form and add next year's fees: both changes or neither.
export async function promoteForm(form: number, nextYearFee: number): Promise<number> {
  return withTransaction(async (client) => {
    const { rows } = await client.query<{ id: number }>(
      "UPDATE students SET form = form + 1 WHERE form = $1 RETURNING id",
      [form],
    );
    const ids = rows.map((r) => r.id);
    // A JavaScript array becomes a PostgreSQL array parameter: = ANY($1)
    await client.query("UPDATE students SET balance = balance + $2 WHERE id = ANY($1)", [ids, nextYearFee]);
    return ids.length;
  });
}
src/main.tsTypeScript
import { pool } from "./db";
import { paymentSummary, promoteForm } from "./reports";
import { createStudent, findByForm, recordPayment } from "./students";

const neema = await createStudent("Neema Kimaro", 6, 90_000);
await recordPayment(neema.id, 30_000);
await recordPayment(neema.id, 20_000);
await createStudent("Ali Mohamed", 6, 120_000);
await createStudent("Rehema Mollel", 5, 10_000);       // already in form 5: must not be charged again
console.log(await paymentSummary(6));

try {
  await promoteForm(6, 150_000);                     // form 7 breaks the CHECK constraint
} catch (err) {
  console.log("Rolled back:", (err as Error).message);
}
console.log("Still in form 6:", (await findByForm(6)).length);

console.log("Promoted from form 4:", await promoteForm(4, 150_000));
console.log(await findByForm(5));
await pool.end();

Check your understanding

  1. Why use WHERE name = $1 with [name] instead of building the SQL with a template string?

  2. What JavaScript type does pg return for SELECT count(*) FROM students?

  3. Why must a transaction use one client from pool.connect() rather than pool.query?

  4. What happens if you forget client.release()?

Ask AI