Skip to content

End-to-End Static Type Checking: PostgreSQL to TypeScript

TypeScript · Type Safety · Developer Experience · December 2025


What if a database schema change could break your TypeScript build before it ever reaches production? With NpgsqlRest's automatic type generation, that's exactly what happens. This post walks through a complete example demonstrating end-to-end static type checking from PostgreSQL functions to TypeScript client code.

The Problem with Traditional API Development

In typical REST API workflows, there's a dangerous gap between your database schema and your client code:

  1. You change a column name in PostgreSQL
  2. Your API continues to work (returning the new column name)
  3. Your frontend code silently breaks at runtime
  4. Users discover the bug, not your build system

This gap exists because types are defined in multiple places - database schemas, API layer, and client code - with no automatic synchronization.

Why PostgreSQL Functions?

PostgreSQL functions are powerful tools that allow developers to encapsulate business logic within the database itself. When you keep logic within the database:

  • Centralized Logic: All application instances interact with the data consistently. This is particularly beneficial in environments where multiple applications or instances access the same database.
  • Improved Performance: By pushing logic down to the database layer, you reduce the need for data to travel back and forth between the database and application.
  • Atomic Operations: Functions can be executed as a single transaction, ensuring operations are atomic and consistent - particularly useful when making changes that involve multiple tables.
  • Ease of Maintenance: When business logic resides in functions, it becomes easier to update and manage without having to redeploy the entire application. Fixing a bug or optimizing a query can be done directly in the database, with immediate effect across all instances.

But perhaps the most underappreciated benefit is static type checking. PostgreSQL functions have explicit return types that the database enforces. This creates a natural contract that can be propagated all the way to your client code.

The Solution: Single Source of Truth

NpgsqlRest solves this by making PostgreSQL the single source of truth:

PostgreSQL Function → NpgsqlRest → Generated TypeScript API Client → Your Application

Any change to the PostgreSQL function signature automatically regenerates the TypeScript interfaces. If your application code references a property that no longer exists, the TypeScript compiler fails the build.

Project Structure

Source Code: The complete working example is available at github.com/NpgsqlRest/npgsqlrest-docs/examples/2_static_type_checking

The example follows this structure:

2_static_type_checking/
├── sql/
│   ├── R__example_2_tables.sql       # Schema and tables (repeatable)
│   ├── A__example_2_get_users.sql    # get_users() function (always run)
│   └── A__example_2_get_posts.sql    # get_posts() function (always run)
├── src/
│   ├── example2Api.ts                # Auto-generated by NpgsqlRest
│   └── app.ts                        # Hand-written application code
├── public/
│   └── index.html                    # HTML entry point
└── config.json                       # NpgsqlRest configuration

The key insight is in the file naming convention:

  • R__ prefix: Repeatable migrations - recreate schema and tables
  • A__ prefix: Always-run migrations - recreate functions on every build

The Database Schema

The schema creates two simple tables: users and posts.

sql
-- R__example_2_tables.sql

-- recreate entire schema example_2
drop schema if exists example_2 cascade;
create schema example_2;

create table example_2.users (
    user_id int primary key generated always as identity,
    username text not null,
    email text not null,
    active boolean not null default true
);

insert into example_2.users (username, email, active) values
('alice', 'alice@example.com', true),
('bob', 'bob@example.com', true),
('charlie', 'charlie@example.com', true);

create table example_2.posts (
    post_id int primary key generated always as identity,
    user_id int references example_2.users(user_id) deferrable,
    content text not null,
    created_at timestamp not null default now()
);

insert into example_2.posts (user_id, content, created_at) values
(1, 'Hello world! This is my first post.', '2024-01-15 10:30:00'),
(1, 'Learning PostgreSQL is fun!', '2024-01-16 14:20:00'),
(2, 'Just joined this platform.', '2024-01-17 09:00:00'),
(3, 'Anyone here interested in databases?', '2024-01-18 11:45:00'),
(2, 'Working on a new project today.', '2024-01-19 16:30:00');

Functions That Define the API Contract

The magic happens in the function definitions. These functions are recreated on every build, which means:

  1. The function signature is always authoritative
  2. NpgsqlRest regenerates TypeScript types from the current function definition
  3. Any mismatch between the function and client code is caught at compile time

get_users()

sql
-- A__example_2_get_users.sql

create or replace function example_2.get_users()
returns setof example_2.users
language sql
as $$
select user_id, username, email, active from example_2.users;
$$;

comment on function example_2.get_users() is 'HTTP GET';

do
$$
begin
    assert (
        select count(*) = 3
        from example_2.get_users()
        where (user_id, username, email, active) in (
            (1, 'alice', 'alice@example.com', true),
            (2, 'bob', 'bob@example.com', true),
            (3, 'charlie', 'charlie@example.com', true)
        )
    ), 'get_users() does not contain expected data';
end;
$$;

Notice:

  • returns setof example_2.users: The return type is the entire users table structure
  • comment on function ... is 'HTTP GET': This tells NpgsqlRest to expose it as a GET endpoint
  • The assert block: Built-in data validation test that runs on every migration

get_posts()

sql
-- A__example_2_get_posts.sql

create or replace function example_2.get_posts()
returns table(
    username text,
    content text,
    created_at timestamp
)
language sql
as $$
select u.username, p.content, p.created_at
from example_2.posts p join example_2.users u using(user_id)
where u.active = true
$$;

comment on function example_2.get_posts() is 'HTTP GET';

do
$$
begin
    assert (
        select count(*) = 5
        from example_2.get_posts()
        where (username, content, created_at) in (
            ('alice', 'Hello world! This is my first post.', '2024-01-15 10:30:00'::timestamp),
            ('alice', 'Learning PostgreSQL is fun!', '2024-01-16 14:20:00'::timestamp),
            ('bob', 'Just joined this platform.', '2024-01-17 09:00:00'::timestamp),
            ('charlie', 'Anyone here interested in databases?', '2024-01-18 11:45:00'::timestamp),
            ('bob', 'Working on a new project today.', '2024-01-19 16:30:00'::timestamp)
        )
    ), 'get_posts() does not contain expected data';
end;
$$;

This function uses an explicit returns table(...) definition, specifying exactly which columns are returned. The function joins users and posts, filtering only active users.

Static Type Checking at the SQL Level

Before we even get to TypeScript, PostgreSQL itself performs static type checking on function definitions. This is a crucial first line of defense.

How PostgreSQL Enforces Return Types

When you define a function with returns table(...) or returns setof, PostgreSQL validates that your function body actually returns the declared types. If there's a mismatch, the function creation fails.

Consider this scenario - you have a function returning username text:

sql
create or replace function example_2.get_posts()
returns table(
    username text,
    content text,
    created_at timestamp
)
language sql
as $$
select u.username, p.content, p.created_at
from example_2.posts p join example_2.users u using(user_id)
where u.active = true
$$;

Now imagine someone changes the username column in the users table to jsonb:

sql
alter table example_2.users alter column username type jsonb using to_jsonb(username);

The next time you run your migration (which recreates the function), PostgreSQL will fail:

ERROR:  return type mismatch in function declared to return record
DETAIL:  Final statement returns jsonb instead of text at column 1.

The database itself caught the type error. This happens at migration time, before any application code runs, before any TypeScript is compiled.

The Return Type Contract

PostgreSQL function return types create an explicit contract:

Return Type DeclarationContract
returns setof usersMust return all columns of users table, with matching types
returns table(username text, ...)Must return exactly these columns with these types
returns intMust return a single integer value
returns voidMust not return a value

This contract is enforced when:

  1. The function is created or replaced
  2. The function is called

If the underlying table structure changes in a way that breaks the contract, you discover it immediately when recreating the function - not when a user triggers the code path in production.

Type Changes Propagate Naturally

The workflow becomes:

  1. Change a column type in PostgreSQL
  2. Function recreation fails during migration (if return type doesn't match)
  3. Update the function to handle the new type
  4. NpgsqlRest regenerates TypeScript interfaces
  5. TypeScript build fails if client code uses the old type
  6. Update client code to match

Every layer validates types. Errors surface at the earliest possible moment.

Why Functions Are Recreated on Every Build

The A__ prefix ensures these SQL files run on every database migration. This is the key to enforcing type checking:

Scenario: You decide to rename content to body in get_posts().

  1. You update the SQL function to return body instead of content
  2. Database migration runs, recreating the function with the new signature
  3. NpgsqlRest regenerates example2Api.ts with the new interface
  4. TypeScript build fails: Property 'content' does not exist on type 'IGetPostsResponse'
  5. You fix app.ts to use body instead of content
  6. Build succeeds

Without the function recreation on each build, the old TypeScript types would persist, and the error would only surface at runtime.

Built-in Testing with SQL Assertions

Each function file includes an assert block that validates the function returns expected data. These assertions run during migration, providing immediate feedback:

sql
do
$$
begin
    assert (
        select count(*) = 3
        from example_2.get_users()
        where (user_id, username, email, active) in (
            (1, 'alice', 'alice@example.com', true),
            (2, 'bob', 'bob@example.com', true),
            (3, 'charlie', 'charlie@example.com', true)
        )
    ), 'get_users() does not contain expected data';
end;
$$;

If the assertion fails, the migration fails, and you know immediately that something is wrong. This creates a safety net ensuring:

  1. The function executes without errors
  2. The function returns the expected structure
  3. Test data is present and correct

This is database-level unit testing that runs on every deployment.

Unit Testing PostgreSQL Functions: Beyond Fixed Data

The example above uses fixed test data that's inserted during migration. This approach is simple and effective, but what about testing with dynamic data or edge cases? Let's explore more robust testing patterns.

Co-located Tests: Function and Test in the Same File

The most practical approach is to place tests directly in the same file as the function being tested. Since these files run on every build (the A__ prefix), your tests execute automatically with every migration.

Here's the complete pattern for A__example_2_get_users.sql:

sql
-- A__example_2_get_users.sql

create or replace function example_2.get_users()
returns setof example_2.users
language sql
as $$
select user_id, username, email, active from example_2.users;
$$;

comment on function example_2.get_users() is 'HTTP GET';

-- Test: Verify function returns expected fixed data
do
$$
begin
    assert (
        select count(*) = 3
        from example_2.get_users()
        where (user_id, username, email, active) in (
            (1, 'alice', 'alice@example.com', true),
            (2, 'bob', 'bob@example.com', true),
            (3, 'charlie', 'charlie@example.com', true)
        )
    ), 'get_users() does not contain expected data';
end;
$$;

The function and its test live together. When the function changes, the test is right there to update.

Test Isolation with Rollback

Unit tests must not interfere with each other. They must maintain perfect isolation. The solution is simple: end your test block with rollback; to undo any data modifications.

sql
-- Test: get_users() returns newly inserted users
do
$$
begin
    -- Insert test data
    insert into example_2.users (username, email, active)
    values ('test_user', 'test@example.com', true);

    -- Verify the function returns our test user
    assert (
        select count(*) = 1
        from example_2.get_users()
        where username = 'test_user'
    ), 'get_users() should return the inserted test user';

    -- Rollback to undo the insert
    rollback;
end;
$$;

The rollback; at the end ensures the test data never persists. Each test starts with a clean slate.

Testing Multiple Scenarios

You can have multiple test blocks in the same file, each testing a different scenario:

sql
-- A__example_2_get_posts.sql

create or replace function example_2.get_posts()
returns table(
    username text,
    content text,
    created_at timestamp
)
language sql
as $$
select u.username, p.content, p.created_at
from example_2.posts p join example_2.users u using(user_id)
where u.active = true
$$;

comment on function example_2.get_posts() is 'HTTP GET';

-- Test 1: Verify function returns expected fixed data
do
$$
begin
    assert (
        select count(*) = 5
        from example_2.get_posts()
        where (username, content, created_at) in (
            ('alice', 'Hello world! This is my first post.', '2024-01-15 10:30:00'::timestamp),
            ('alice', 'Learning PostgreSQL is fun!', '2024-01-16 14:20:00'::timestamp),
            ('bob', 'Just joined this platform.', '2024-01-17 09:00:00'::timestamp),
            ('charlie', 'Anyone here interested in databases?', '2024-01-18 11:45:00'::timestamp),
            ('bob', 'Working on a new project today.', '2024-01-19 16:30:00'::timestamp)
        )
    ), 'get_posts() does not contain expected data';
end;
$$;

-- Test 2: Verify inactive users' posts are excluded
do
$$
declare
    _count int;
begin
    -- Make charlie inactive
    update example_2.users set active = false where username = 'charlie';

    -- Count posts from charlie (should be 0 since he's now inactive)
    select count(*) into _count
    from example_2.get_posts()
    where username = 'charlie';

    assert _count = 0,
        'get_posts() should not return posts from inactive users';

    rollback;
end;
$$;

-- Test 3: New posts from active users appear in results
do
$$
declare
    _result record;
begin
    -- Insert a new post
    insert into example_2.posts (user_id, content, created_at)
    values (1, 'Brand new post!', now());

    -- Verify it appears in results
    select * into _result
    from example_2.get_posts()
    where content = 'Brand new post!';

    assert _result.username = 'alice',
        'New post should appear with correct username';

    rollback;
end;
$$;

Testing Against Empty Tables

To test how a function behaves with no data:

sql
-- Test: Function handles empty tables gracefully
do
$$
declare
    _count int;
begin
    -- Delete all data
    delete from example_2.posts;
    delete from example_2.users;

    -- Test that function returns 0 rows (not an error)
    select count(*) into _count from example_2.get_users();
    assert _count = 0,
        'get_users() should return 0 rows when table is empty';

    select count(*) into _count from example_2.get_posts();
    assert _count = 0,
        'get_posts() should return 0 rows when table is empty';

    rollback;
end;
$$;

Deferrable Constraints: The Key to Test Data

Notice in our schema definition:

sql
user_id int references example_2.users(user_id) deferrable

The deferrable keyword is crucial for testing. By default, foreign key constraints are checked immediately when you insert a row. With deferrable constraints, checks can be deferred until the end of the transaction.

Since test transactions are rolled back anyway, deferrable constraints allow you to insert test data without worrying about inserting data into a dozen related tables first. This dramatically simplifies test setup.

sql
-- Test: Posts with deferred constraints
do
$$
begin
    -- Defer all constraint checks until transaction end
    set constraints all deferred;

    -- Insert post with non-existent user_id (constraint check deferred)
    insert into example_2.posts (user_id, content, created_at)
    values (999, 'Orphan post', now());

    -- We can test our function here
    -- The FK violation won't be checked because we'll rollback

    rollback;
end;
$$;

Why Database Testing is Fast

A common misconception is that database testing is slow. In reality, PostgreSQL testing can be faster than application-level testing because:

  1. No network overhead: Tests run inside the database
  2. Transaction rollback is instant: No need to truncate tables or restore backups
  3. Parallel execution: Tests in separate transactions can run concurrently
  4. No ORM overhead: Direct SQL execution

The key is proper isolation through transactions, not through recreating entire databases for each test.

Addressing Common Myths

"Testing in a database is impossible"

This is simply not true for PostgreSQL. While testing stored procedures might be difficult in some database systems, PostgreSQL provides all the tools you need: anonymous blocks, assertions, and transaction control.

"Testing in a database is slow"

It's actually faster than most alternatives. When your test ends with rollback;, there's no cleanup needed - no truncating tables, no restoring backups. The rollback is nearly instantaneous. Compare this to spinning up Docker containers or re-seeding test databases.

"You can't do TDD with SQL"

You absolutely can. Write your test first in the function file:

sql
-- A__example_2_create_user.sql

-- Test first (this will fail until we implement create_user)
do
$$
begin
    perform example_2.create_user('newuser', 'new@example.com');

    assert exists (
        select 1 from example_2.users where username = 'newuser'
    ), 'User should be created';

    rollback;
end;
$$;

-- Now implement the function to make the test pass
create or replace function example_2.create_user(_username text, _email text)
returns void
language sql
as $$
insert into example_2.users (username, email, active)
values (_username, _email, true);
$$;

Red-green-refactor works just as well in SQL as anywhere else. The test runs on every build, ensuring the function continues to work as expected.

The Generated TypeScript Client

NpgsqlRest automatically generates example2Api.ts based on the PostgreSQL function signatures:

typescript
// autogenerated at 2025-12-31T12:06:45.2201980+01:00

const baseUrl = "http://127.0.0.1:8080";

interface IGetPostsResponse {
    username: string | null;
    content: string | null;
    createdAt: string | null;
}

interface IGetUsersResponse {
    userId: number | null;
    username: string | null;
    email: string | null;
    active: boolean | null;
}

/**
* function example_2.get_posts()
* returns table(
*     username text,
*     content text,
*     created_at timestamp without time zone
* )
*/
export async function getPosts() : Promise<{
    status: number,
    response: IGetPostsResponse[],
    error: {status: number; title: string; detail?: string | null} | undefined
}> {
    const response = await fetch(baseUrl + "/api/example-2/get-posts", {
        method: "GET",
        headers: {
            "Content-Type": "application/json"
        },
    });
    return {
        status: response.status,
        response: response.ok ? await response.json() as IGetPostsResponse[] : undefined!,
        error: !response.ok ? await response.json() as {status: number; title: string; detail?: string | null} : undefined
    };
}

/**
* function example_2.get_users()
* returns table(
*     user_id integer,
*     username text,
*     email text,
*     active boolean
* )
*/
export async function getUsers() : Promise<{
    status: number,
    response: IGetUsersResponse[],
    error: {status: number; title: string; detail?: string | null} | undefined
}> {
    const response = await fetch(baseUrl + "/api/example-2/get-users", {
        method: "GET",
        headers: {
            "Content-Type": "application/json"
        },
    });
    return {
        status: response.status,
        response: response.ok ? await response.json() as IGetUsersResponse[] : undefined!,
        error: !response.ok ? await response.json() as {status: number; title: string; detail?: string | null} : undefined
    };
}

Key features of the generated code:

  1. Interfaces match the PostgreSQL return types exactly - Column names are converted from snake_case to camelCase
  2. Nullable fields use | null - Reflecting PostgreSQL's nullable columns
  3. JSDoc comments include the original function signature - Making it easy to trace back to the source
  4. Typed error handling - Errors have a consistent structure with status, title, and optional detail

The Application Code

The hand-written application code in app.ts demonstrates how type safety flows through to the UI layer:

typescript
import { getPosts, getUsers } from "./example2Api.ts";

const app = document.getElementById("app")!;

// Render a single user row - uses IGetUsersResponse properties
function renderUserRow(user: {
    userId: number | null;
    username: string | null;
    email: string | null;
    active: boolean | null
}) {
    const row = document.createElement("tr");
    row.innerHTML = `
        <td>${user.userId}</td>
        <td>${user.username ?? "Anonymous"}</td>
        <td>${user.email ?? "N/A"}</td>
        <td>${user.active ? "✓" : "✗"}</td>
    `;
    return row;
}

// Render a single post - uses IGetPostsResponse properties
function renderPost(post: {
    username: string | null;
    content: string | null;
    createdAt: string | null
}) {
    const article = document.createElement("article");
    article.className = "post";
    article.innerHTML = `
        <header><strong>${post.username ?? "Anonymous"}</strong></header>
        <p>${post.content ?? ""}</p>
        <footer><small>${
            post.createdAt
                ? new Date(post.createdAt).toLocaleString()
                : "Unknown date"
        }</small></footer>
    `;
    return article;
}

// Load and display users
async function loadUsers() {
    const { status, response: users, error } = await getUsers();

    if (status !== 200) {
        app.innerHTML = `<p>Error loading users: ${error?.title}</p>`;
        return;
    }

    const table = document.createElement("table");
    table.innerHTML = `
        <thead>
            <tr>
                <th>ID</th>
                <th>Username</th>
                <th>Email</th>
                <th>Active</th>
            </tr>
        </thead>
    `;
    const tbody = document.createElement("tbody");

    // Static type checking happens here!
    // If IGetUsersResponse changes (e.g., "username" renamed to "name"),
    // TypeScript will fail the build with: Property 'username' does not exist
    for (const user of users) {
        tbody.appendChild(renderUserRow(user));
    }

    table.appendChild(tbody);
    app.appendChild(table);
}

// Load and display posts
async function loadPosts() {
    const { status, response: posts, error } = await getPosts();

    if (status !== 200) {
        app.innerHTML += `<p>Error loading posts: ${error?.title}</p>`;
        return;
    }

    const postsSection = document.createElement("section");
    postsSection.innerHTML = "<h2>Posts</h2>";

    // Static type checking happens here!
    // If IGetPostsResponse changes (e.g., "content" renamed to "body"),
    // TypeScript will fail the build with: Property 'content' does not exist
    for (const post of posts) {
        postsSection.appendChild(renderPost(post));
    }

    app.appendChild(postsSection);
}

// Initialize the app
async function init() {
    app.innerHTML = "<h1>Users & Posts</h1>";
    await loadUsers();
    await loadPosts();
}

init();

The critical type-checking points are in the loop bodies where we access properties:

  • user.userId, user.username, user.email, user.active
  • post.username, post.content, post.createdAt

If any of these properties are renamed or removed in the PostgreSQL function, the TypeScript build fails immediately.

The Complete Type-Safe Workflow

Here's what happens during development:

bash
# 1. Run database migrations (recreates functions every time)
bun run db:up

# 2. Start NpgsqlRest (regenerates example2Api.ts from current functions)
bun run dev

# 3. Build TypeScript (catches any type mismatches)
bun run build

If you change a column name in a PostgreSQL function:

  1. db:up recreates the function with the new column
  2. NpgsqlRest detects the schema change and regenerates example2Api.ts
  3. bun run build fails because app.ts references the old column name
  4. You update app.ts to use the new name
  5. Build succeeds

The error is caught at build time, not runtime. No more mysterious undefined values in production.

Configuration

The NpgsqlRest configuration enables type generation:

json
{
  "ApplicationName": "static_type_checking",
  "StaticFiles": {
    "RootPath": "./2_static_type_checking/public"
  },
  "NpgsqlRest": {
    "IncludeSchemas": [ "example_2" ],
    "RequiresAuthorization": false,
    "ClientCodeGen": {
      "FilePath": "./2_static_type_checking/src/{0}Api.ts"
    }
  }
}

The {0} placeholder in FilePath is replaced with the schema name, so example_2 becomes example2Api.ts.

Benefits of This Approach

1. Single Source of Truth

PostgreSQL functions define both the API contract and the TypeScript types. No manual synchronization required.

2. Compile-Time Safety

Schema changes break the build, not production. You discover problems during development, not from user reports.

3. Automatic Documentation

The generated code includes JSDoc comments with the original PostgreSQL function signature. Your IDE shows exactly what the database returns.

4. Database-Level Testing

SQL assertions validate your functions return the expected data structure. Tests run on every migration.

5. No Runtime Type Checking Overhead

Types are enforced at compile time. The generated JavaScript has zero overhead compared to hand-written fetch calls.

Conclusion

End-to-end static type checking transforms how you build database-backed applications. By making PostgreSQL the single source of truth and regenerating types on every build, you catch schema mismatches before they reach production.

The key ingredients:

  1. PostgreSQL function return types that create explicit contracts enforced by the database
  2. Always-run migrations (A__ prefix) that recreate functions and run tests on every build
  3. Co-located tests in the same file as the function, using simple do $$ ... rollback; end; $$; blocks
  4. Deferrable constraints that enable isolated, fast unit tests
  5. NpgsqlRest's type generation that creates TypeScript interfaces from PostgreSQL signatures
  6. TypeScript compilation that catches property mismatches

The result is a multi-layered type safety system:

PostgreSQL Schema
      ↓ (enforced by DB)
PostgreSQL Functions + Tests
      ↓ (generated by NpgsqlRest)
TypeScript Interfaces
      ↓ (enforced by tsc)
Application Code

Every layer validates types. Errors surface at the earliest possible moment - during development, not in production. Schema changes naturally propagate through the entire stack, with compilers and databases acting as your safety net.

Unit testing in PostgreSQL isn't slow or difficult. With simple anonymous blocks, transaction rollback, and deferrable constraints, you can achieve the same TDD workflows you'd use in any other language - but with the added benefit of testing your actual database logic, not a mock of it.

Why This Stack is Superior

Compare this approach to traditional stacks:

Traditional StackThis Stack
Database schemaDatabase schema
ORM models
Repository layer
Service layerPostgreSQL functions
Controller layer
API documentation
TypeScript types (manual)TypeScript types (generated)
Integration testsCo-located SQL tests

You eliminate entire layers. No ORM mappings to maintain. No repository pattern boilerplate. No controller classes. No manual API documentation. No hand-written TypeScript interfaces that drift out of sync with reality.

The code you don't write has no bugs.

Performance That Scales

This isn't just about developer experience - it's about production performance. NpgsqlRest's architecture eliminates the overhead that traditional frameworks accumulate:

  • No ORM overhead: Direct PostgreSQL protocol communication
  • No routing framework: Endpoints derived from database metadata
  • No serialization layer: PostgreSQL's native JSON functions handle serialization
  • Minimal memory allocation: Optimized hot paths using buffer pooling

The result? NpgsqlRest consistently outperforms traditional frameworks. In benchmarks, it achieves over 5,000 requests per second at 100 concurrent users - nearly 35% faster than the second-place framework and over 50% faster than hand-optimized Rust implementations.

Maximum Type Safety, Minimum Code

Traditional approaches require you to define types in multiple places and hope they stay synchronized:

Database → ORM → Service → Controller → OpenAPI → Client SDK → Frontend

Each arrow is a potential desynchronization point. Each layer requires manual maintenance.

With NpgsqlRest:

Database → Generated Client → Frontend

One source of truth. Zero manual synchronization. Types flow automatically from database to browser.

The Bottom Line

This stack delivers:

  • Full end-to-end type safety from PostgreSQL to TypeScript
  • The least amount of code - no boilerplate layers to maintain
  • Superior performance - direct database-to-HTTP pipeline
  • Built-in testing that runs on every deployment
  • Automatic documentation through generated code

When you combine database-enforced types, automated type generation, compile-time checking, and co-located SQL tests, you get a development experience where "it works on my machine" actually means "it will work in production."

NpgsqlRest delivers the best performance with the least code - a rare combination that eliminates entire categories of bugs and maintenance burden.

Released under the MIT License.