Dynamic Return Types
How Cerial computes return types based on select and include options using GetModelPayload, ResolveFieldSelect, and ApplyObjectSelect.
Cerial's most powerful type-level feature is dynamic return types. The TypeScript type of your query result changes based on the select and include options you provide. Select only id and name, and the result type contains only those fields. Try to access a field you didn't select, and TypeScript gives you an error at compile time.
How It Works
For each model, Cerial generates a GetModelPayload<S, I> type where:
Sis theselectoption type (which fields to return)Iis theincludeoption type (which relations to load)
This type uses TypeScript conditional types to compute the exact return shape:
// Simplified concept:
type GetUserPayload<S, I> = S extends UserSelect
? { [K in SelectedKeys<S>]: ResolveFieldType<User[K], S[K]> }
: User & (I extends UserInclude ? GetUserIncludePayload<I> : {});When S is undefined (no select), you get the full model type. When S is a specific select object, you get only the fields where the value is true (or a sub-select object). Include types are intersected to add relation data.
Key Utility Types
| Type | Purpose |
|---|---|
GetModelPayload<S, I> | Main return type resolver. Combines selected fields with included relations. |
ResolveFieldSelect<FieldType, SelectValue> | Resolves a single field's type based on its select value. true returns the full field type; an object applies sub-field selection for embedded objects. |
ApplyObjectSelect<T, S> | Recursively applies sub-field selection to an object type, producing a narrowed type with only the selected sub-fields. |
SelectedKeys<T> | Extracts keys from a select object where the value is not false or undefined. |
GetIncludePayload<M, R, I> | Computes the type for included relations, respecting cardinality (single vs array) and nested options. |
Examples
No Select: Full Model
When you don't pass a select option, you get the full model type:
const user = await db.User.findOne({
where: { id: '123' },
});
// Type: User
// Resolves to: GetUserPayload<undefined, undefined>
user.id; // CerialId ✓
user.email; // string ✓
user.name; // string ✓
user.age; // number | undefined ✓
user.createdAt; // Date ✓Select Specific Fields
When you pass select, only the selected fields appear in the return type:
const user = await db.User.findOne({
where: { id: '123' },
select: { id: true, name: true },
});
// Type: { id: CerialId; name: string }
// Resolves to: GetUserPayload<{ id: true; name: true }, undefined>
user.id; // CerialId ✓
user.name; // string ✓
user.email; // TypeScript error! Property 'email' does not existInclude Relations
When you pass include, relation data is added to the model type:
const user = await db.User.findOne({
where: { id: '123' },
include: { posts: true },
});
// Type: User & { posts: Post[] }
// Resolves to: GetUserPayload<undefined, { posts: true }>
user.id; // CerialId ✓
user.name; // string ✓
user.posts; // Post[] ✓Include with Nested Options
Included relations can have their own where, select, orderBy, limit, and offset:
const user = await db.User.findOne({
where: { id: '123' },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
limit: 5,
},
},
});
// Type: User & { posts: Post[] }
// The where/orderBy/limit filter at runtime but don't change the typeSelect + Include Combined
You can use both select and include together:
const user = await db.User.findOne({
where: { id: '123' },
select: { id: true, name: true },
include: { posts: true },
});
// Type: { id: CerialId; name: string } & { posts: Post[] }
user.id; // CerialId ✓
user.name; // string ✓
user.posts; // Post[] ✓
user.email; // TypeScript error!Object Sub-Field Selection
Embedded object fields support sub-field selection for type narrowing:
const user = await db.User.findOne({
where: { id: '123' },
select: { address: { city: true, state: true } },
});
// Type: { address: { city: string; state: string } }
user.address.city; // string ✓
user.address.state; // string ✓
user.address.street; // TypeScript error!Compare with selecting the full object:
const user = await db.User.findOne({
where: { id: '123' },
select: { address: true },
});
// Type: { address: Address }
user.address.city; // string ✓
user.address.street; // string ✓ (all fields available)Optional Object Sub-Field Selection
Optional object fields preserve their optionality through sub-field selection:
const user = await db.User.findOne({
where: { id: '123' },
select: { shipping: { city: true } },
});
// Type: { shipping?: { city: string } }
// The ? is preserved — shipping may not exist
if (user.shipping) {
user.shipping.city; // string ✓
}Array Object Sub-Field Selection
Array-of-object fields apply sub-field selection to each element:
const user = await db.User.findOne({
where: { id: '123' },
select: { locations: { lat: true, lng: true } },
});
// Type: { locations: { lat: number; lng: number }[] }
user.locations[0].lat; // number ✓
user.locations[0].lng; // number ✓Select Within Include
You can use select inside an include option to narrow relation types:
const user = await db.User.findOne({
where: { id: '123' },
include: {
posts: {
select: { id: true, title: true },
},
},
});
// Type: User & { posts: { id: CerialId; title: string }[] }
user.posts[0].title; // string ✓
user.posts[0].content; // TypeScript error!The select within include is type-level narrowing only. At runtime, SurrealDB returns full related objects. The type narrowing ensures your code only accesses the fields you've declared interest in, but the actual data may contain more fields.
findMany Differences
The findMany method returns an array, and the same type resolution applies to each element:
const users = await db.User.findMany({
where: { isActive: true },
select: { id: true, name: true },
});
// Type: { id: CerialId; name: string }[]
users[0].id; // CerialId ✓
users[0].email; // TypeScript error!Type Resolution Summary
| Query Options | Return Type |
|---|---|
| No select, no include | Model |
select: { a: true, b: true } | { a: TypeOfA; b: TypeOfB } |
include: { rel: true } | Model & { rel: RelatedType } |
select: { a: true } + include: { rel: true } | { a: TypeOfA } & { rel: RelatedType } |
select: { obj: { sub: true } } | { obj: { sub: TypeOfSub } } |
select: { obj: true } | { obj: FullObjectType } |
include: { rel: { select: { a: true } } } | Model & { rel: { a: TypeOfA } } |
Typed Tuples in Transactions
When using $transaction, each query's return type is preserved at its position in the result array:
const [user, count, exists] = await client.$transaction([
client.db.User.findOne({ where: { id: '123' }, select: { name: true } }),
client.db.Post.count({ where: { published: true } }),
client.db.Tag.exists({ where: { name: 'ts' } }),
]);
// user: { name: string } | null
// count: number
// exists: booleanTypeScript knows the exact type at each position. It's not a generic unknown[] but a precise typed tuple.