Advanced Type Inference
Sluice's killer feature is end-to-end type inference through aggregation pipelines. Every stage computes a precise output type that becomes the next stage's input — no manual generics, no casts, no as unknown as MyType.
This page showcases the type system in action with real-world patterns.
Pipeline Type Flow
The core idea: each stage transforms the document shape, and Sluice infers every intermediate type automatically.
import { Schema as S } from "@effect/schema";
import { registry, $match, $group, $project, $sort, $addFields, $unwind } from "sluice-orm";
const OrderSchema = S.Struct({
_id: S.String,
userId: S.String,
amount: S.Number,
items: S.Array(S.Struct({
productId: S.String,
name: S.String,
price: S.Number,
quantity: S.Number,
category: S.String,
})),
status: S.Literal("pending", "paid", "shipped", "delivered"),
createdAt: S.Date,
});
const db = registry("8.0", { orders: OrderSchema });
const { orders } = db(client.db("shop"));
const categoryReport = await orders
.aggregate(
// Stage 1: Filter — output type stays the same
$match($ => ({ status: "paid" })),
// Type: { _id: string; userId: string; amount: number;
// items: { productId: string; name: string; price: number; quantity: number; category: string }[];
// status: "pending" | "paid" | "shipped" | "delivered"; createdAt: Date }
// Stage 2: Unwind items array — each doc now has a single item
$unwind("$items"),
// Type: { _id: string; userId: string; amount: number;
// items: { productId: string; name: string; price: number; quantity: number; category: string };
// status: "pending" | "paid" | "shipped" | "delivered"; createdAt: Date }
// ↑ items is now a single object, not an array!
// Stage 3: Group by category — completely new shape
$group($ => ({
_id: "$items.category",
revenue: $.sum($.multiply("$items.price", "$items.quantity")),
unitsSold: $.sum("$items.quantity"),
orderCount: $.sum(1),
})),
// Type: { _id: string; revenue: number; unitsSold: number; orderCount: number }
// Stage 4: Add computed fields
$addFields($ => ({
avgRevenuePerOrder: $.divide("$revenue", "$orderCount"),
})),
// Type: { _id: string; revenue: number; unitsSold: number; orderCount: number;
// avgRevenuePerOrder: number }
// Stage 5: Sort — type unchanged
$sort({ revenue: -1 }),
// Type: { _id: string; revenue: number; unitsSold: number; orderCount: number;
// avgRevenuePerOrder: number }
)
.toList();
// categoryReport: {
// _id: string;
// revenue: number;
// unitsSold: number;
// orderCount: number;
// avgRevenuePerOrder: number;
// }[]
What just happened: We went from OrderSchema to a completely different shape through 5 stages, and TypeScript knows the exact type at every step. No generics were harmed.
Type-Aware Autocomplete
When writing expressions, the $ builder constrains field references by type. This means your editor only shows fields of the correct type in autocomplete:
$.multiply("$→ only shows numeric fields ($amount,$items.price,$items.quantity, ...)$.concat("$→ only shows string fields ($userId,$items.name,$items.category, ...)$.gte("$→ only shows numeric/date fields$.filter({ input: "$→ only shows array fields ($items, ...)
This is powered by filtered path types — NumericFieldRef<T>, StringFieldRef<T>, ArrayFieldRef<T>, etc. — that walk your schema's dot-paths and keep only those whose resolved type matches the operator's constraint.
$project($ => ({
// ✅ TypeScript allows — "$amount" resolves to number
doubled: $.multiply("$amount", 2),
// ❌ TypeScript error — "$userId" resolves to string, not number
bad: $.multiply("$userId", 2),
// ✅ TypeScript allows — "$userId" resolves to string
greeting: $.concat("Hello, ", "$userId"),
// ❌ TypeScript error — "$amount" resolves to number, not string
bad2: $.concat("$amount", " dollars"),
}))
Type Narrowing Tricks
Sluice's type system goes beyond basic inference — it performs type narrowing that removes nullability and produces precise literal types.
$.ifNull — Removing Nullability
When a field is T | null, $.ifNull narrows the result to just T by providing a fallback:
const MonsterSchema = S.Struct({
_id: S.String,
name: S.String,
legacyScore: S.NullOr(S.Number), // number | null
deletedAt: S.NullOr(S.Date), // Date | null
});
$addFields($ => ({
// legacyScore is number | null → after ifNull, it's number
safeScore: $.ifNull("$legacyScore", 0),
// Type: number
// Can chain multiple fallbacks — returns the first non-null
safeName: $.ifNull("$nickname", "$name", "Anonymous"),
// Type: string
}))
Under the hood, $.ifNull uses FirstNotNil<T> — a recursive type that walks the argument tuple and returns the first type that isn't null | undefined.
$.switch — Union of Literal Types
$.switch infers a precise union of literal types from all then values and the default:
$project($ => ({
ageBand: $.switch({
branches: [
{ case: $.lt("$age", 18), then: "minor" },
{ case: $.lt("$age", 65), then: "adult" },
],
default: "senior",
}),
// Type: "minor" | "adult" | "senior"
// ↑ Not string — a precise union of the exact literals you wrote!
priceRange: $.switch({
branches: [
{ case: $.lte("$price", 10), then: 1 },
{ case: $.lte("$price", 50), then: 2 },
{ case: $.lte("$price", 100), then: 3 },
],
default: 4,
}),
// Type: 1 | 2 | 3 | 4
}))
This works because branches are captured with const Br, preserving literal types in then values. The result type is Br[number]["then"] | D — a distributed union over all branches.
$.cond — Narrowed Branch Types
$.cond similarly narrows to the union of then and else:
$addFields($ => ({
tier: $.cond({
if: $.gte("$totalSpent", 1000),
then: "premium",
else: "standard",
}),
// Type: "premium" | "standard"
// Nested cond chains keep precise types
bracket: $.cond({
if: $.lt("$score", 30),
then: "low",
else: $.cond({
if: $.lt("$score", 70),
then: "mid",
else: "high",
}),
}),
// Type: "low" | "mid" | "high"
}))
$.concat — Template Literal Types
$.concat produces template literal types when mixing literals with field references:
$project($ => ({
// All literals → exact template literal
label: $.concat("Hello, ", "World"),
// Type: "Hello, World"
// Field ref (string) + literal → template literal with string
greeting: $.concat("Hello, ", "$name"),
// Type: `Hello, ${string}`
// Multiple segments
tag: $.concat("user_", "$userId", "_v2"),
// Type: `user_${string}_v2`
}))
Under the hood, ConcatStrings<T> recursively builds `${First}${ConcatStrings<Rest>}`, so any literal segment stays literal and any string distributes via TypeScript's template literal mechanics.
$.mergeObjects — Null-Free Accumulation
$.mergeObjects uses MergeTwo<A, B> which skips null and undefined entirely:
// MergeTwo<A, B>:
// A extends null | undefined → B (null is skipped)
// B extends null | undefined → A (null is skipped)
// otherwise → MergeWithIndexOverride<A, B> (last-wins merge)
$addFields($ => ({
merged: $.mergeObjects(
"$metadata", // { version: string; counts: { views: number } }
{ extra: "value" as const },
),
// Type: { version: string; counts: { views: number }; extra: "value" }
// ↑ No null contamination from the accumulator chain
// Overlapping keys: last wins
overridden: $.mergeObjects(
{ a: 1, b: "old" },
{ b: "new", c: true },
),
// Type: { a: number; b: string; c: boolean }
// ↑ b is string (from last object), not number | string
}))
Array Transformations
Sluice types $map, $filter, and $reduce with full inference of the iteration variables ($$this, $$value, custom as names).
$map — Transform Array Elements
$project($ => ({
// Map items to just their names
itemNames: $.map({
input: "$items",
as: "item",
in: "$$item.name",
}),
// Type: string[]
// Map with expression
itemTotals: $.map({
input: "$items",
as: "item",
in: $ => $.multiply("$$item.price", "$$item.quantity"),
}),
// Type: number[]
}))
$filter — Type-Safe Array Filtering
$project($ => ({
// Filter keeps the element type — only the cond callback is required
expensiveItems: $.filter({
input: "$items",
as: "item",
cond: $ => $.gte("$$item.price", 100),
}),
// Type: { productId: string; name: string; price: number; quantity: number; category: string }[]
highScores: $.filter({
input: "$scores",
cond: $ => $.gte("$$this", 80),
}),
// Type: number[]
}))
$filter and $reduce require callback syntax for their cond/in arguments: $ => expr. Using a bare expression is a compile error (CallbackOnlyError).
$reduce — Fold Arrays with Type Tracking
$project($ => ({
// Sum all quantities — $$value is number, $$this is the element type
totalQuantity: $.reduce({
input: "$items",
initialValue: 0,
in: $ => $.add("$$value", "$$this.quantity"),
}),
// Type: number
// Collect rarities into an array
allRarities: $.reduce({
input: $.map({ input: "$items", as: "item", in: "$$item.rarity" }),
initialValue: [] as string[],
in: $ => $.concatArrays("$$value", ["$$this"]),
}),
// Type: string[]
}))