Query Parameters with Generics: Type-Safe API Requests
Learn to handle API query parameters with TypeScript generics. Build dynamic, type-safe URLs while maintaining full intellisense for your task API client.

Previously in this course, we built a generic request wrapper in TypeScript to standardize our fetch calls. Today, we’re going to extend that foundation to handle one of the most common sources of runtime errors: building dynamic URLs with query parameters.
When working with APIs, you often need to filter, sort, or paginate data. Manually concatenating strings like ?status=open&limit=10 is error-prone and bypasses TypeScript’s type checking. By using generics, we can map a configuration object directly to a query string, ensuring that every parameter we pass is valid.
Defining Query Parameter Types
Instead of passing raw strings to our API client, we define an interface that represents the shape of our query. This allows us to use standard TypeScript features like optional properties, as we learned in handling optional properties in TypeScript.
For our task application, let’s define a TaskQueryParams interface:
TYPESCRIPTinterface TaskQueryParams { status?: CE9178">'pending' | CE9178">'completed' | CE9178">'in-progress'; limit?: number; sortBy?: CE9178">'dueDate' | CE9178">'createdAt'; order?: CE9178">'asc' | CE9178">'desc'; }
By defining this interface, we gain immediate type safety. If a developer tries to pass status: 'done' (when the API only supports 'pending' or 'completed'), TypeScript will flag it immediately.
Using Generics for Query Objects
Now, we need a utility function to transform this object into a URL-friendly string. This is where generics become powerful. We want a function that accepts any object and converts it into a URLSearchParams string.
TYPESCRIPTfunction buildQueryString<T extends Record<string, any>>(params: T): string { const searchParams = new URLSearchParams(); for (const key in params) { if (params[key] !== undefined) { searchParams.append(key, String(params[key])); } } return searchParams.toString(); }
The <T extends Record<string, any>> constraint ensures that whatever we pass into this function is an object. Using URLSearchParams is the production-grade way to handle this, as it automatically handles URL encoding for special characters.
Building Dynamic URLs
Let's integrate this into our existing task client. We want to be able to fetch tasks with specific criteria while keeping our code clean and reusable.
TYPESCRIPT// Our updated request function async function getTasks(query?: TaskQueryParams) { let url = CE9178">'/api/tasks'; if (query) { const queryString = buildQueryString(query); url += CE9178">`?${queryString}`; } // Reuse the request wrapper from previous lessons return await apiClient<Task[]>(url); } // Usage getTasks({ status: CE9178">'pending', limit: 5 }); // Result: /api/tasks?status=pending&limit=5
By combining the generic buildQueryString with our TaskQueryParams interface, we’ve created a system where adding a new query parameter is as simple as adding a field to our interface. The compiler handles the rest.
Hands-on Exercise
Refactor your existing task client to support a sortBy parameter.
- Update your
TaskQueryParamsinterface to includesortBy. - Ensure the
buildQueryStringfunction correctly handles the new parameter. - Call
getTaskswith{ sortBy: 'dueDate' }and verify the output URL in your console.
Common Pitfalls
- Forgetting to handle
undefined: If you passundefinedvalues toURLSearchParams, it might serialize them as the string"undefined". Always check forundefinedornullbefore appending to the search parameters. - Over-complicating the generic: While we used
Record<string, any>here, avoid making the generic too complex. If you find yourself needing to handle nested objects in query parameters, consider using a library likeqs, but keep the type definition flat for simple API requests. - Ignoring URL Encoding: Never manually concatenate strings with
+. If a user enters a task title that contains an&or?, manual concatenation will break your URL structure.URLSearchParamshandles this safely.
FAQ
Q: Why use Record<string, any> instead of just object?
A: Record<string, any> explicitly tells TypeScript that we are dealing with an object that has string keys, which is required for iterating over the object properties using for...in.
Q: Should I use this for complex filtering logic?
A: For simple key-value pairs, yes. For deeply nested filters (e.g., ?filter[status][eq]=open), standard URLSearchParams may need custom logic. Refer to REST API design best practices for handling more complex query requirements.
Recap
We’ve learned to bridge the gap between static TypeScript interfaces and dynamic URL generation. By defining clear interfaces for query parameters and utilizing a generic utility function, we ensure that our API client remains type-safe, maintainable, and free of manual string manipulation errors.
Up next: We will implement full GET requests using our new query logic to fetch filtered task lists.
Work with me

Next.js Full-Stack Web App Development
A fast, SEO-ready full-stack web app built with Next.js 16 — from idea to deployed product, by an engineer who ships to production.

Laravel REST API Development
Clean, secure, well-documented Laravel REST APIs — the backend engine for your app, mobile client, or SaaS. Built by an API specialist.


