← Back to list

Decoding TypeScript Internals: A Practical Approach

In this article, we’ll pull back the curtain and decode a complex type by building a simplified QueryBuilder from scratch. This example is…

Manikandan Raman · 2025-07-10 18:33 · 4 claps · 4.8 min read
#typescript #typeorm #orm #query-builder #knexjs
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

Decoding TypeScript Internals: A Practical Approach

In this article, we’ll pull back the curtain and decode a complex type by building a simplified QueryBuilder from scratch. This example is a perfect showcase for how advanced TypeScript features come together to create a flexible, type-safe API. Our mission is to break down this powerful type to understand its internal workings, proving that even the most intricate type definitions are just a clever combination of core concepts.

The Final Product: A Glimpse of Type-Safety

Here’s a look at what our final product will achieve. Notice how each step in the chain provides a new level of type safety.

// Define your database model
interface User {
    id: number;
    name: string;
    email: string;
    isActive: boolean;
}
// Our Query Builder in action
const userQuery = createQueryBuilder<User>();
const result = userQuery
    .select(['id', 'name'])  // The `select` method narrows the return type
    .where('email', 'contains', 'example.com')  // The `where` method provides context-aware operators
    .orderBy('createdAt', 'asc')  // The `orderBy` method ensures valid field names
    .execute();
// The final result is perfectly typed!
// `result` will be a Promise<{id: number, name: string}[]>;
// If you try to access a non-selected field, TypeScript will throw an error:
// result.then(users => {
//   console.log(users[0].email); // Error: 'email' does not exist on type '{id: number; name: string;}'.
// });

Pretty neat, right? Now let’s dive into how this is built.

The Core Code: The QueryBuilder Type

At the heart of our solution is the QueryBuilder type itself. It's a generic interface that tracks not just the base model (T), but also the selected fields (S).

type QueryBuilder<T extends Record<string, any>, S extends keyof T = keyof T> = {
    select: <K extends keyof T>(fields: K[]) => QueryBuilder<T, K>;
    where: <K extends keyof T>(
        field: K,
        operator: T[K] extends string ? 'equals' | 'contains' | 'startsWith' :
                  T[K] extends number ? 'equals' | 'gt' | 'lt' | 'gte' | 'lte' :
                  T[K] extends boolean ? 'equals' : never,
        value: T[K]
    ) => QueryBuilder<T, S>;
    orderBy: <K extends keyof T>(field: K, direction: 'asc' | 'desc') => QueryBuilder<T, S>;
    execute: () => Promise<Pick<T, S>[]>;
};

This single type is responsible for all the magic.

function createQueryBuilder<T extends Record<string, any>>(): QueryBuilder<T> {
    return {
        select: <K extends keyof T>(fields: K[]) => createQueryBuilder<T>() as QueryBuilder<T, K>,
        where: (field, operator, value) => createQueryBuilder<T>() as QueryBuilder<T, any>,
        orderBy: (field, direction) => createQueryBuilder<T>() as QueryBuilder<T, any>,
        execute: async () => {
            return [] as any;
        }
    };
}

The above function is used to create a query builder object.

Now let’s break it down, method by method.

Decoding the QueryBuilder Type

Before we look at the methods, let’s understand the generics:

  • **<T extends Record<string, any>>**: This is our first generic parameter, T. It represents the full data model, like our User interface. It's constrained to be an object (Record<string, any>).
  • **<S extends keyof T = keyof T>: This is the second generic parameter, S. It represents the set of selected fields**.
  • S extends keyof T: This constraint ensures that the selected fields are always valid keys of the original model T.
  • = keyof T: This is a default value. When the query builder is first created, S defaults to keyof T, meaning all fields are "selected" by default.

This S generic is the key to our entire system! It's a type-level state variable that gets updated as we chain methods.

Decoding the select method

The select method is the most important part of our type-level state management.

select: <K extends keyof T>(fields: K[]) => QueryBuilder<T, K>;
  • **<K extends keyof T>**: This is a new, local generic K. It captures the union of field names passed to the select method (e.g., 'id' | 'name'). This provides autocompletion for the fields array.
  • **QueryBuilder<T, K>: This is the core of the magic. The method returns a new QueryBuilder instance where our S generic (the selected fields) is now narrowed** to K. Any subsequent method in the chain will now operate on this narrowed type.

Decoding the where and orderBy methods

These methods add conditions to our query. They are powerful because they provide context-aware autocompletion for their arguments.

where: <K extends keyof T>(
    field: K,
    operator: T[K] extends string ? 'equals' | 'contains' | ... : // Conditionals
              T[K] extends number ? 'equals' | 'gt' | ... :
              T[K] extends boolean ? 'equals' : never,
    value: T[K]
) => QueryBuilder<T, S>;
orderBy: <K extends keyof T>(field: K, direction: 'asc' | 'desc') => QueryBuilder<T, S>;
  • **<K extends keyof T>**: Both methods use a generic K to ensure the field argument is a valid key from the original model T.
  • The Conditional Type: The where method's operator parameter uses a powerful Conditional Type.
  • T[K] extends string ? 'equals' | 'contains' | ...: This looks at the type of the field T[K] (e.g., T['name'] which is string). If it's a string, TypeScript will only allow a set of string-specific operators like 'contains'.
  • If it’s a number, it allows number-specific operators like 'gt' or 'lt'.
  • This provides a beautiful developer experience with perfect autocompletion for operators.
  • **QueryBuilder<T, S>: Notice that these methods return the QueryBuilder with the original S generic**. This is because adding a where or orderBy clause does not change which fields we are ultimately selecting.

Decoding the execute method

The execute method is the grand finale. This is where all the type information we've collected is finally used to define the return type.

execute: () => Promise<Pick<T, S>[]>;
  • **Promise<...>**: The method returns a Promise, as a query is typically an asynchronous operation.
  • **Pick<T, S>: This is a powerful built-in Utility Type**.
  • Pick takes two arguments: an object type (T) and a union of keys (S).
  • It returns a new object type that contains only the properties specified in S.
  • Since our S generic was correctly narrowed by the select method, Pick<T, S> gives us a new type that contains only the fields we selected!

The Implementation (For the Curious)

While the type definitions are the interesting part, here is a simplified implementation that makes it all work.

// Create the query builder
function createQueryBuilder<T extends Record<string, any>>(): QueryBuilder<T> {
    return {
        // The implementation uses type assertions (`as`) to satisfy the compiler
        // because the runtime logic doesn't explicitly track the types.
        select: <K extends keyof T>(fields: K[]) => createQueryBuilder<T>() as QueryBuilder<T, K>,
        where: (field, operator, value) => createQueryBuilder<T>() as QueryBuilder<T, any>,
        orderBy: (field, direction) => createQueryBuilder<T>() as QueryBuilder<T, any>,
        execute: async () => {
            // In a real application, this would run the query against a database
            // and return the results.
            return [] as any;
        }
    };
}

The implementation uses simple type assertions (as QueryBuilder<T, K>) to tell TypeScript, "trust me, the type of what I'm returning matches the return type definition." This is a very common pattern when building type-level libraries, where the type system performs all the checks at compile-time and the runtime code simply fulfills the contract.

Conclusion

By leveraging a few key advanced TypeScript features — generics, conditional types, indexed, literal and utility types — we were able to create a highly flexible, expressive, and completely type-safe query builder. This pattern can be applied to any API or data manipulation layer to enforce correctness at compile time, leading to more robust and maintainable code.

LinkedIn Profile: https://www.linkedin.com/in/manikandan-raman/


메타데이터
post_id
4d9ac03de8a8
slug
decoding-typescript-internals-a-practical-approach-4d9ac03de8a8
url
https://medium.com/@maniyuvan446/decoding-typescript-internals-a-practical-approach-4d9ac03de8a8
canonical_url
https://medium.com/@maniyuvan446/decoding-typescript-internals-a-practical-approach-4d9ac03de8a8
author_url
https://medium.com/@maniyuvan446
status
ok
fetched_at
2026-07-19 01:37:51