Generate Typesafe OData v4 Queries with Ease. odata-builder ensures your queries are correct as you write them, eliminating worries about incorrect query formats.
Important: This version introduces breaking changes to lambda expressions and function syntax. Please review the Migration Guide below.
- ✨ Complete TypeScript Support with perfect autocomplete
- 🔒 Type-Safe Queries - catch errors at compile time
- 📊 Comprehensive OData v4 Support including functions, lambda expressions, and advanced filtering
- 🚀 Performance Optimized with minimal runtime overhead
- 🎯 Developer Experience focused with extensive IntelliSense support
Install odata-builder using your preferred package manager:
npm install --save odata-builder
or
yarn add odata-builder
import { OdataQueryBuilder } from 'odata-builder';
type Product = {
id: number;
name: string;
price: number;
category: string;
isActive: boolean;
}
const query = new OdataQueryBuilder<Product>()
.filter({ field: 'isActive', operator: 'eq', value: true })
.filter({ field: 'price', operator: 'gt', value: 100 })
.orderBy({ field: 'name', orderDirection: 'asc' })
.top(10)
.toQuery();
// Result: ?$filter=isActive eq true and price gt 100&$orderby=name asc&$top=10
const queryBuilder = new OdataQueryBuilder<Product>()
.filter({ field: 'name', operator: 'eq', value: 'iPhone' })
.filter({ field: 'price', operator: 'gt', value: 500 })
.toQuery();
// Result: ?$filter=name eq 'iPhone' and price gt 500
// Count with filters
const countQuery = new OdataQueryBuilder<Product>()
.count()
.filter({ field: 'isActive', operator: 'eq', value: true })
.toQuery();
// Result: ?$count=true&$filter=isActive eq true
// Count only (no data)
const countOnlyQuery = new OdataQueryBuilder<Product>()
.count(true)
.filter({ field: 'category', operator: 'eq', value: 'Electronics' })
.toQuery();
// Result: /$count?$filter=category eq 'Electronics'
const query = new OdataQueryBuilder<Product>()
.select('name', 'price', 'category')
.orderBy({ field: 'price', orderDirection: 'desc' })
.orderBy({ field: 'name', orderDirection: 'asc' })
.top(20)
.skip(40)
.toQuery();
// Result: ?$select=name,price,category&$orderby=price desc,name asc&$top=20&$skip=40
import { Guid } from 'odata-builder';
type User = {
id: Guid;
name: string;
}
const query = new OdataQueryBuilder<User>()
.filter({
field: 'id',
operator: 'eq',
value: 'f92477a9-5761-485a-b7cd-30561e2f888b',
removeQuotes: true // Optional: removes quotes around GUID
})
.toQuery();
// Result: ?$filter=id eq f92477a9-5761-485a-b7cd-30561e2f888b
const query = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
operator: 'contains',
value: 'apple',
ignoreCase: true
})
.toQuery();
// Result: ?$filter=contains(tolower(name), 'apple')
const query = new OdataQueryBuilder<Product>()
.filter({
logic: 'and',
filters: [
{ field: 'isActive', operator: 'eq', value: true },
{
logic: 'or',
filters: [
{ field: 'category', operator: 'eq', value: 'Electronics' },
{ field: 'category', operator: 'eq', value: 'Books' }
]
}
]
})
.toQuery();
// Result: ?$filter=(isActive eq true and (category eq 'Electronics' or category eq 'Books'))
// String length
const query = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: { type: 'length' },
operator: 'gt',
value: 10
})
.toQuery();
// Result: ?$filter=length(name) gt 10
// String concatenation
const concatQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: {
type: 'concat',
values: [' - ', { fieldReference: 'category' }]
},
operator: 'eq',
value: 'iPhone - Electronics'
})
.toQuery();
// Result: ?$filter=concat(name, ' - ', category) eq 'iPhone - Electronics'
// Substring extraction
const substringQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: { type: 'substring', start: 0, length: 5 },
operator: 'eq',
value: 'iPhone'
})
.toQuery();
// Result: ?$filter=substring(name, 0, 5) eq 'iPhone'
// String search position
const indexQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: { type: 'indexof', value: 'Pro' },
operator: 'gt',
value: -1
})
.toQuery();
// Result: ?$filter=indexof(name, 'Pro') gt -1
// Basic arithmetic
const query = new OdataQueryBuilder<Product>()
.filter({
field: 'price',
function: { type: 'add', operand: 100 },
operator: 'gt',
value: 1000
})
.toQuery();
// Result: ?$filter=price add 100 gt 1000
// Field references in arithmetic
const fieldRefQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'price',
function: { type: 'mul', operand: { fieldReference: 'taxRate' } },
operator: 'lt',
value: 500
})
.toQuery();
// Result: ?$filter=price mul taxRate lt 500
// Rounding functions
const roundQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'price',
function: { type: 'round' },
operator: 'eq',
value: 100
})
.toQuery();
// Result: ?$filter=round(price) eq 100
type Order = {
id: number;
createdAt: Date;
updatedAt: Date;
}
// Extract date parts
const yearQuery = new OdataQueryBuilder<Order>()
.filter({
field: 'createdAt',
function: { type: 'year' },
operator: 'eq',
value: 2024
})
.toQuery();
// Result: ?$filter=year(createdAt) eq 2024
// Current time comparison
const nowQuery = new OdataQueryBuilder<Order>()
.filter({
field: 'updatedAt',
function: { type: 'now' },
operator: 'gt',
value: new Date('2024-01-01')
})
.toQuery();
// Result: ?$filter=now() gt 2024-01-01T00:00:00.000Z
// Date extraction
const dateQuery = new OdataQueryBuilder<Order>()
.filter({
field: 'createdAt',
function: { type: 'date', field: { fieldReference: 'createdAt' } },
operator: 'eq',
value: '2024-01-15',
removeQuotes: true
})
.toQuery();
// Result: ?$filter=date(createdAt) eq 2024-01-15
// Direct boolean function calls (no operator needed)
const containsQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: { type: 'contains', value: 'Pro' }
})
.toQuery();
// Result: ?$filter=contains(name, 'Pro')
// Boolean function with explicit comparison
const notContainsQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: { type: 'contains', value: 'Basic' },
operator: 'eq',
value: false
})
.toQuery();
// Result: ?$filter=contains(name, 'Basic') eq false
Transform field values before comparison:
// String transformations
const query = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
operator: 'eq',
value: 'iphone',
transform: ['tolower', 'trim']
})
.toQuery();
// Result: ?$filter=tolower(trim(name)) eq 'iphone'
// Numeric transformations
const roundQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'price',
operator: 'eq',
value: 100,
transform: ['round']
})
.toQuery();
// Result: ?$filter=round(price) eq 100
// Date transformations
type Event = { startDate: Date; }
const dateQuery = new OdataQueryBuilder<Event>()
.filter({
field: 'startDate',
operator: 'eq',
value: 2024,
transform: ['year']
})
.toQuery();
// Result: ?$filter=year(startDate) eq 2024
type Article = {
title: string;
tags: string[];
}
const query = new OdataQueryBuilder<Article>()
.filter({
field: 'tags',
lambdaOperator: 'any',
expression: {
field: '',
operator: 'eq',
value: 'technology'
}
})
.toQuery();
// Result: ?$filter=tags/any(s: s eq 'technology')
type Product = {
name: string;
reviews: Array<{
rating: number;
comment: string;
verified: boolean;
}>;
}
const query = new OdataQueryBuilder<Product>()
.filter({
field: 'reviews',
lambdaOperator: 'any',
expression: {
field: 'rating',
operator: 'gt',
value: 4
}
})
.toQuery();
// Result: ?$filter=reviews/any(s: s/rating gt 4)
// String functions in arrays
const lengthQuery = new OdataQueryBuilder<Article>()
.filter({
field: 'tags',
lambdaOperator: 'any',
expression: {
field: '',
function: { type: 'length' },
operator: 'gt',
value: 5
}
})
.toQuery();
// Result: ?$filter=tags/any(s: length(s) gt 5)
// Complex nested expressions
const complexQuery = new OdataQueryBuilder<Product>()
.filter({
field: 'reviews',
lambdaOperator: 'all',
expression: {
field: 'comment',
function: { type: 'tolower' },
operator: 'contains',
value: 'excellent'
}
})
.toQuery();
// Result: ?$filter=reviews/all(s: contains(tolower(s/comment), 'excellent'))
type Category = {
name: string;
products: Array<{
name: string;
variants: Array<{
color: string;
size: string;
}>;
}>;
}
const nestedQuery = new OdataQueryBuilder<Category>()
.filter({
field: 'products',
lambdaOperator: 'any',
expression: {
field: 'variants',
lambdaOperator: 'any',
expression: {
field: 'color',
operator: 'eq',
value: 'red'
}
}
})
.toQuery();
// Result: ?$filter=products/any(s: s/variants/any(t: t/color eq 'red'))
const query = new OdataQueryBuilder<Product>()
.search('laptop gaming')
.toQuery();
// Result: ?$search=laptop%20gaming
import { SearchExpressionBuilder } from 'odata-builder';
const searchQuery = new OdataQueryBuilder<Product>()
.search(
new SearchExpressionBuilder()
.term('laptop')
.and()
.phrase('high performance')
.or()
.group(
new SearchExpressionBuilder()
.term('gaming')
.and()
.not(new SearchExpressionBuilder().term('budget'))
)
)
.toQuery();
// Result: ?$search=laptop%20AND%20%22high%20performance%22%20OR%20(gaming%20AND%20(NOT%20budget))
const builder = new SearchExpressionBuilder()
.term('laptop') // Single term: laptop
.phrase('exact match') // Quoted phrase: "exact match"
.and() // Logical AND
.or() // Logical OR
.not( // Logical NOT
new SearchExpressionBuilder().term('budget')
)
.group( // Grouping with parentheses
new SearchExpressionBuilder().term('gaming')
);
type Order = {
id: number;
customer: {
name: string;
email: string;
};
}
const query = new OdataQueryBuilder<Order>()
.expand('customer')
.toQuery();
// Result: ?$expand=customer
type Order = {
id: number;
customer: {
profile: {
address: {
city: string;
country: string;
};
};
};
}
const query = new OdataQueryBuilder<Order>()
.expand('customer/profile/address') // Full autocomplete support
.toQuery();
// Result: ?$expand=customer/profile/address
Create reusable, type-safe filter functions:
import { FilterFields, FilterOperators } from 'odata-builder';
const createStringFilter = <T>(
field: FilterFields<T, string>,
operator: FilterOperators<string>,
value: string
) => {
return new OdataQueryBuilder<T>()
.filter({ field, operator, value })
.toQuery();
};
// Usage with full type safety and autocomplete
const result = createStringFilter(product, 'contains', 'iPhone');
type Product = {
id: number;
name: string;
price: number;
category: string;
isActive: boolean;
tags: string[];
reviews: Array<{
rating: number;
comment: string;
verified: boolean;
}>;
createdAt: Date;
}
const complexQuery = new OdataQueryBuilder<Product>()
// Text search
.search(
new SearchExpressionBuilder()
.term('laptop')
.and()
.phrase('high performance')
)
// Combined filters
.filter({
logic: 'and',
filters: [
// Active products only
{ field: 'isActive', operator: 'eq', value: true },
// Price range
{ field: 'price', operator: 'ge', value: 500 },
{ field: 'price', operator: 'le', value: 2000 },
// Has gaming tag
{
field: 'tags',
lambdaOperator: 'any',
expression: {
field: '',
operator: 'eq',
value: 'gaming'
}
},
// Has good reviews
{
field: 'reviews',
lambdaOperator: 'any',
expression: {
logic: 'and',
filters: [
{ field: 'rating', operator: 'gt', value: 4 },
{ field: 'verified', operator: 'eq', value: true }
]
}
},
// Created this year
{
field: 'createdAt',
function: { type: 'year' },
operator: 'eq',
value: 2024
}
]
})
// Sorting and pagination
.orderBy({ field: 'price', orderDirection: 'asc' })
.orderBy({ field: 'name', orderDirection: 'asc' })
.top(20)
.skip(0)
.select('name', 'price', 'category')
.count()
.toQuery();
-
Operators:
eq
,ne
,gt
,ge
,lt
,le
,contains
,startswith
,endswith
,indexof
-
String Functions:
concat
,contains
,endswith
,indexof
,length
,startswith
,substring
,tolower
,toupper
,trim
-
Math Functions:
add
,sub
,mul
,div
,mod
,round
,floor
,ceiling
-
Date Functions:
year
,month
,day
,hour
,minute
,second
,now
,date
,time
-
Lambda Operators:
any
,all
with nested support -
Logic Operators:
and
,or
with grouping -
Query Options:
$filter
,$select
,$expand
,$orderby
,$top
,$skip
,$count
,$search
- Advanced Features: Field references, property transformations, case-insensitive filtering, GUID support
- Strict TypeScript: Perfect autocomplete for all properties and methods
- Compile-time Validation: Catch errors before runtime
- Operator Restrictions: Only valid operators for each field type
- Deep Object Navigation: Full autocomplete for nested properties
- Function Parameter Validation: Ensures correct function usage
Version 0.8.0 introduces breaking changes to improve type safety and add OData function support. Here's how to update your code:
Before (v0.7.x):
// Old syntax with innerProperty
const query = new OdataQueryBuilder<MyType>()
.filter({
field: 'items',
operator: 'contains',
value: 'test',
lambdaOperator: 'any',
innerProperty: 'name',
ignoreCase: true,
})
.toQuery();
After (v0.8.0):
// New syntax with expression object
const query = new OdataQueryBuilder<MyType>()
.filter({
field: 'items',
lambdaOperator: 'any',
expression: {
field: 'name',
operator: 'contains',
value: 'test',
ignoreCase: true
}
})
.toQuery();
Before (v0.7.x):
// Old syntax for simple arrays
const query = new OdataQueryBuilder<MyType>()
.filter({
field: 'tags',
operator: 'contains',
value: 'important',
lambdaOperator: 'any',
ignoreCase: true,
})
.toQuery();
After (v0.8.0):
// New syntax - use empty string for field
const query = new OdataQueryBuilder<MyType>()
.filter({
field: 'tags',
lambdaOperator: 'any',
expression: {
field: '', // Empty string for array elements
operator: 'contains',
value: 'important',
ignoreCase: true
}
})
.toQuery();
Version 0.8.0 adds comprehensive OData function support:
// String functions
const lengthFilter = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
function: { type: 'length' },
operator: 'gt',
value: 10
})
.toQuery();
// Math functions
const roundedPrice = new OdataQueryBuilder<Product>()
.filter({
field: 'price',
function: { type: 'round' },
operator: 'eq',
value: 100
})
.toQuery();
// Date functions
const yearFilter = new OdataQueryBuilder<Order>()
.filter({
field: 'createdAt',
function: { type: 'year' },
operator: 'eq',
value: 2024
})
.toQuery();
Transform field values before comparison:
// String transformations
const query = new OdataQueryBuilder<Product>()
.filter({
field: 'name',
operator: 'eq',
value: 'iphone',
transform: ['tolower', 'trim'] // Chain transformations
})
.toQuery();
// Result: ?$filter=tolower(trim(name)) eq 'iphone'
// Deep nesting support
const nestedQuery = new OdataQueryBuilder<Category>()
.filter({
field: 'products',
lambdaOperator: 'any',
expression: {
field: 'variants',
lambdaOperator: 'any',
expression: {
field: 'color',
operator: 'eq',
value: 'red'
}
}
})
.toQuery();
// Result: ?$filter=products/any(s: s/variants/any(t: t/color eq 'red'))
- ✅ Update lambda filters: Replace
innerProperty
withexpression: { field: '...' }
- ✅ Fix array lambdas: Use
field: ''
for simple array element filtering - ✅ Test your queries: All existing functionality should work with the new syntax
- ✅ Explore new features: Consider using OData functions and property transformations
If you encounter issues during migration:
- Check the extensive examples in this README
- Review the TypeScript autocomplete suggestions
- Open an issue if you need assistance
Your contributions are welcome! If there's a feature you'd like to see in odata-builder, or if you encounter any issues, please feel free to open an issue or submit a pull request.
This project is licensed under the MIT License.