Strongly-typed TypeScript utilities for array grouping, SQL-like joins, and data aggregation
A modern, zero-dependency library for grouping arrays and performing SQL-like joins in TypeScript with full type safety and excellent performance.
- 🗄️ Database result processing - Join data from multiple Prisma/TypeORM queries
- 📊 Data aggregation - Group and transform arrays with type safety
- 🔄 API response transformation - Combine related data from multiple endpoints
- 📈 Analytics and reporting - Multi-dimensional data grouping
- 🎯 Alternative to Lodash/Ramda - With better TypeScript support
- 🔒 Type-Safe: Soporte completo de TypeScript con tipado estricto
- 🎯 Zero Dependencies: Sin dependencias externas en runtime
- ⚡ Performance: Complejidad O(n + m) para joins
- 🔧 Inmutable: Nunca muta los datos de entrada
- 📦 Tree-Shakeable: Soporte ESM y CJS
- 🎨 Composable: Encadena múltiples operaciones fácilmente
- 🔑 Composite Keys: Joins con claves compuestas (múltiples propiedades)
npm install ts-array-joinsyarn add ts-array-joinspnpm add ts-array-joinsimport { groupByKey, attachChildren } from "ts-array-joins";
// Agrupar arrays
const users = [
{ id: 1, role: "admin", name: "Ana" },
{ id: 2, role: "user", name: "Juan" },
];
const byRole = groupByKey(users, "role");
// { admin: [...], user: [...] }
// Joins (one-to-many)
const orders = [
{ id: 101, userId: 1, total: 50 },
{ id: 102, userId: 1, total: 100 },
];
const usersWithOrders = attachChildren({
parents: users,
children: orders,
parentKey: "id",
childKey: "userId",
as: "orders",
});
// Array<User & { orders: Order[] }>Agrupa un array por una propiedad con inferencia de tipos completa.
type User = { id: number; role: 'admin' | 'user'; name: string };
const users: User[] = [...];
const grouped = groupByKey(users, 'role');
// Type: Record<'admin' | 'user', User[]>Agrupa usando una función selectora personalizada.
const byFirstLetter = groupBy(users, (u) => u.name[0]);
// Record<string, User[]>Crea grupos anidados usando múltiples claves.
type Sale = { country: string; city: string; amount: number };
const sales: Sale[] = [...];
const nested = groupByMany(sales, ['country', 'city']);
// Record<string, Record<string, Sale[]>>Agrupa y transforma cada grupo.
const totalByUser = groupByTransform(
orders,
(o) => o.userId,
(orders) => orders.reduce((sum, o) => sum + o.total, 0)
);
// Record<number, number>Realiza un join uno-a-muchos (como SQL LEFT JOIN con agrupación).
type User = { id: number; name: string };
type Order = { id: number; userId: number; total: number };
const result = attachChildren({
parents: users,
children: orders,
parentKey: "id",
childKey: "userId",
as: "orders",
});
// Array<User & { orders: Order[] }>Características:
- Los padres sin hijos obtienen array vacío
[] - Complejidad O(n + m)
- Completamente type-safe
Realiza un join uno-a-uno (como SQL LEFT JOIN).
type Address = { id: number; userId: number; city: string };
const result = attachChild({
parents: users,
children: addresses,
parentKey: "id",
childKey: "userId",
as: "address",
});
// Array<User & { address: Address | null }>Características:
- Se usa el primer match cuando existen múltiples hijos
- Los padres sin match obtienen
null - Preserva todos los elementos padre
Join usando funciones selectoras personalizadas.
type Product = { sku: string; name: string };
type Review = { productCode: string; rating: number };
const result = joinBySelectors({
parents: products,
children: reviews,
parentSelector: (p) => p.sku,
childSelector: (r) => r.productCode,
as: "reviews",
mode: "many", // o 'one'
});Casos de uso:
- Propiedades con nombres diferentes
- Claves de join computadas
- Lógica de matching compleja
Cuando tus relaciones se definen por múltiples propiedades (ej: SKU + Origen), la librería ofrece dos estrategias:
Crea objetos anidados similar a groupByMany - intuitivo y fácil de depurar.
import { attachChildrenNested, attachChildNested } from "ts-array-joins";
type Product = { sku: string; origin: string; name: string };
type Inventory = { sku: string; origin: string; quantity: number };
const products: Product[] = [
{ sku: "SKU-A", origin: "origin1", name: "Widget A1" },
{ sku: "SKU-A", origin: "origin2", name: "Widget A2" },
{ sku: "SKU-B", origin: "origin1", name: "Gadget B1" },
];
const inventory: Inventory[] = [
{ sku: "SKU-A", origin: "origin1", quantity: 100 },
{ sku: "SKU-A", origin: "origin1", quantity: 50 },
{ sku: "SKU-A", origin: "origin2", quantity: 75 },
];
// One-to-many con claves compuestas anidadas
const result = attachChildrenNested({
parents: products,
children: inventory,
parentKeys: ["sku", "origin"],
childKeys: ["sku", "origin"],
as: "inventoryRecords",
});
// Estructura interna:
// {
// "SKU-A": {
// "origin1": [inv1, inv2],
// "origin2": [inv3]
// },
// "SKU-B": {
// "origin1": []
// }
// }
// Result: Array<Product & { inventoryRecords: Inventory[] }>One-to-one con claves anidadas:
type Price = { sku: string; origin: string; amount: number };
const prices: Price[] = [
{ sku: "SKU-A", origin: "origin1", amount: 99.99 },
{ sku: "SKU-A", origin: "origin2", amount: 89.99 },
];
const withPrices = attachChildNested({
parents: products,
children: prices,
parentKeys: ["sku", "origin"],
childKeys: ["sku", "origin"],
as: "price",
});
// Array<Product & { price: Price | null }>Usa claves compuestas serializadas para máxima eficiencia.
import { attachChildrenComposite, attachChildComposite } from "ts-array-joins";
const result = attachChildrenComposite({
parents: products,
children: inventory,
parentKeys: ["sku", "origin"],
childKeys: ["sku", "origin"],
as: "inventoryRecords",
});
// Estructura interna:
// {
// "SKU-A||~~||origin1": [inv1, inv2],
// "SKU-A||~~||origin2": [inv3]
// }
// Mismo resultado que la estrategia anidada| Característica | Anidada | Serializada |
|---|---|---|
| Performance | O(n + m) | O(n + m) |
| Memoria | Ligeramente más | Ligeramente menos |
| Claves máx | 2-3 óptimo | Cualquier cantidad |
| Debugging | ✅ Intuitivo | |
| Similitud API | Como groupByMany |
Enfoque único |
Ambas producen resultados idénticos - elige según tus preferencias:
- Usa Anidada cuando: 2-3 claves, la legibilidad importa, similar a
groupByMany - Usa Serializada cuando: 4+ claves, máxima performance, datasets muy grandes
type Product = {
sku: string;
region: string;
supplier: string;
name: string;
};
type Stock = {
sku: string;
region: string;
supplier: string;
quantity: number;
warehouse: string;
};
type Price = {
sku: string;
region: string;
supplier: string;
amount: number;
currency: string;
};
const products: Product[] = [...];
const stock: Stock[] = [...];
const prices: Price[] = [...];
// Componer múltiples joins con clave compuesta de 3 elementos
const enriched = attachChildNested({
parents: attachChildrenNested({
parents: products,
children: stock,
parentKeys: ['sku', 'region', 'supplier'],
childKeys: ['sku', 'region', 'supplier'],
as: 'stockRecords'
}),
children: prices,
parentKeys: ['sku', 'region', 'supplier'],
childKeys: ['sku', 'region', 'supplier'],
as: 'pricing'
});
// Type: Array<Product & {
// stockRecords: Stock[];
// pricing: Price | null
// }>type User = { id: number; name: string };
type Order = { id: number; userId: number; total: number };
type Address = { id: number; userId: number; city: string };
const users: User[] = [...];
const orders: Order[] = [...];
const addresses: Address[] = [...];
// Encadenar múltiples joins
const enrichedUsers = attachChild({
parents: attachChildren({
parents: users,
children: orders,
parentKey: 'id',
childKey: 'userId',
as: 'orders',
}),
children: addresses,
parentKey: 'id',
childKey: 'userId',
as: 'address',
});
// Type: Array<User & { orders: Order[]; address: Address | null }>import { pipe } from "./utils"; // Tu utilidad pipe
const result = pipe(
users,
(u) =>
attachChildren({
parents: u,
children: orders,
parentKey: "id",
childKey: "userId",
as: "orders",
}),
(u) =>
attachChild({
parents: u,
children: addresses,
parentKey: "id",
childKey: "userId",
as: "address",
})
);// Endpoint de API para obtener usuarios con sus datos relacionados
async function getUsersWithRelations() {
const [users, orders, addresses] = await Promise.all([
db.users.findMany(),
db.orders.findMany(),
db.addresses.findMany(),
]);
return attachChild({
parents: attachChildren({
parents: users,
children: orders,
parentKey: "id",
childKey: "userId",
as: "orders",
}),
children: addresses,
parentKey: "id",
childKey: "userId",
as: "primaryAddress",
});
}Todas las operaciones de join usan Map para lookups O(1):
- Complejidad Temporal: O(n + m) donde n = padres, m = hijos
- Complejidad Espacial: O(m) para el índice + O(n) para resultados
- Mejores Prácticas:
- Pre-filtrar arrays cuando sea posible
- Usar
attachChilden lugar deattachChildrenpara relaciones one-to-one - Considerar usar
groupByTransformpara agregar datos durante la agrupación
Esta librería requiere TypeScript 5.0+ con modo estricto:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}- ✅ Tipado más fuerte
- ✅ Zero dependencies
- ✅ Menor tamaño de bundle
- ✅ Operaciones de join incluidas
- ✅ Más legible
- ✅ Menos propenso a errores
- ✅ Mejor performance (uso optimizado de Map)
- ✅ Composable
- ✅ Funciona con cualquier dato de array
- ✅ No requiere base de datos
- ✅ Type-safe en tiempo de compilación
- ❌ No optimiza consultas de base de datos
¡Las contribuciones son bienvenidas! Por favor lee nuestra Guía de Contribución para detalles.
MIT © Fernando Barrón