The generated client types every result from the arguments you pass, not from the model. A select of two fields gives a record with exactly two fields; add a relation and the relation's shape follows its own select. The types are derived from the same contract the server enforces, so what compiles is what the server accepts.
select: only the selected fields exist
const users = await avClient.User.find.many({ select: ["id", "email"] });
// readonly { readonly id: number; readonly email: string }[]
users[0].email; // string
users[0].name; // error TS2339: Property 'name' does not exist on type ...With neither select nor include, a record has every readable scalar field and no relations. Scalars map to their natural types: Int to number, a nullable String? to string | null, an enum to the union of its values ("USER" | "ADMIN"), DateTime to Date, Boolean to boolean.
const ada = await avClient.User.find.unique({ where: { id: 1 } });
// { readonly id: number; readonly email: string; readonly name: string | null;
// readonly role: "USER" | "ADMIN"; readonly createdAt: Date }Your editor shows these plain types too. Hover a result and each field reads as its value type (name: string | null, role: "USER" | "ADMIN", published: boolean, publishedAt: Date | null), and an included relation as its fields. A declaration file your project emits ("declaration": true) writes the same short types, so a service whose methods return results without annotations stays small. This holds for framework.application, framework.client and framework.serverClient results and for generated clients.
A field the client layer hides or makes write-only is not in these types, and is not a valid select entry; an operator a field does not offer is not a valid filter either:
await avClient.User.find.many({ select: ["passwordHash"] }); // compile errorinclude and nested select
include adds relations to the default scalars. select can nest a relation with its own select, where, orderBy and paging:
const withPosts = await avClient.User.find.unique({ where: { id: 1 }, include: ["posts"] });
// default User fields plus: readonly posts: readonly { id, title, body, views, published, meta, authorId }[]
const posts = await avClient.Post.find.many({
select: ["id", "title", { author: { select: ["id", "name"] } }],
limit: 10,
});
// readonly { readonly id: number; readonly title: string;
// readonly author: { readonly id: number; readonly name: string | null } }[]A to-one relation is an object (or null when the relation is optional); a to-many relation is a readonly array. A relation named as a bare string uses the target's default projection; a customized one uses object form.
$count
$count is a reducer on a to-many relation, not a field. It changes the shape of that relation's result:
Nested select | Result type |
|---|---|
["id", "title"] | readonly { id; title }[] |
["$count"] | { readonly count: number } |
["id", "title", "$count"] | { readonly data: readonly { id; title }[]; readonly count: number } |
const users = await avClient.User.find.many({
select: ["id", { posts: { select: ["id", "title", "$count"] } }],
});
// readonly { readonly id: number;
// readonly posts: { readonly data: readonly { readonly id: number; readonly title: string }[];
// readonly count: number } }[]
// at runtime: [{ id: 1, posts: { data: [{ id: 1, title: "Hello" }], count: 1 } }, { id: 2, posts: { data: [], count: 0 } }]
const counts = await avClient.User.find.many({ select: ["id", { posts: { select: ["$count"] } }], limit: 1 });
// [{ id: 1, posts: { count: 1 } }]When the nested relation has a where and paging, count is the number of matching rows before limit and offset, and data is the page.
$defaults
"$defaults" in a select stands for the level's default projection, so the type is the default fields plus whatever else the list names, all present:
select | Result type |
|---|---|
["$defaults"] | the default fields |
["$defaults", "email"] | the default fields and email: string |
["id", "email"] | { id; email } only |
A field that a policy governs is not in the default fields. It is typed as present, never optional or nullable, wherever a select names it. A request that may not read it is refused (A4002) instead of returning a gap.
Results are readonly
Every result type is readonly, arrays included, so a returned record cannot be mutated in place. Copy it into a new object, or type a variable as readonly User[]:
users[0].id = 5; // error TS2540: Cannot assign to 'id' because it is a read-only property.
users.push(users[0]); // error TS2339: Property 'push' does not exist on type 'readonly ...[]'.
const list: readonly User[] = await avClient.User.find.many({}); // `User` is a named type from the generated clientfirst returns null, unique throws, count is a number
| Call | Result | When nothing matches |
|---|---|---|
find.first, update.first, delete.first | Record | null | null |
find.unique, update.unique, delete.unique | Record | throws AvNotFoundError (A2003) |
find.many | readonly Record[] | [] |
find.count, create.count, update.count, delete.count | number | 0 |
create.one, upsert.unique | Record | not applicable |
create.many, update.many | readonly Record[] | [] |
The type system follows: a first result must be checked before use, a unique result need not.
const first = await avClient.User.find.first({ select: ["id"] });
first.id; // error TS18047: 'first' is possibly 'null'.
first?.id; // number | undefined
const n: number = await avClient.User.find.count();Failures are typed errors
The client resolves to the operation's data and throws on every failure. Branch on the error class or its code, never on the message:
| Class | Typical codes |
|---|---|
AvValidationError | A2004, A2007, A2009 (with cause.issues, each with a V code and a path) |
AvNotFoundError | A2003 |
AvConflictError | A2008, A2013, A2014 |
AvContractMismatchError | A2005 |
AvAuthError | A4000, A4001, A4002 |
AvInternalError | A3000, A3001, A3002, A3003, A3004 |
AvProtocolError | A2000, A2001, A2002, A2006, A2010, A2011, A2012 (a request the server could not route or interpret) |
AvTransportError | no response, or a response that is not a framework envelope (not an AvError; it has no code) |
import avClient, { AvConflictError } from "./api/AvClient";
try {
await avClient.User.create.one({ data: { email: "ada@example.com", passwordHash: "..." } });
} catch (e) {
if (e instanceof AvConflictError) {
e.code; // "A2008": the email already exists
}
}On the server, the same results and errors
Server-side calls through framework.application.*, framework.client.*, framework.serverClient.* and framework.transaction settle exactly like the client: they resolve the typed data and throw the same error classes, exported from @aventara/core (import { AvConflictError } from "@aventara/core"). See Server-side usage.
The types of framework.application and framework.client follow your exposure layer by layer, exactly as the generated client's do. A hidden field is not in the types. A field whose select, filter, order, create or update capability you took away is refused in that position and left out of the result types, and a where offers each field only the filter operators it allows (inside not too), so an operator the server would refuse with V1006 is a type error. A removed operation is not on the object. framework.client reads the root's exemptions with the client overlay's over them; framework.application the root's with the application overlay's, so a field hidden at the root is hidden on framework.application too. framework.appTx and framework.clientTx follow the same rules.
const n = await framework.application.User.find.count({}); // number