Core concepts

Results and typing

How the generated client types results exactly from select, include and $count, why results are readonly, and how first misses, counts and errors behave.

Read as Markdown

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

Code
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.

Code
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:

Code
await avClient.User.find.many({ select: ["passwordHash"] });   // compile error

include and nested select

include adds relations to the default scalars. select can nest a relation with its own select, where, orderBy and paging:

Code
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 selectResult type
["id", "title"]readonly { id; title }[]
["$count"]{ readonly count: number }
["id", "title", "$count"]{ readonly data: readonly { id; title }[]; readonly count: number }
Code
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:

selectResult 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[]:

Code
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 client

first returns null, unique throws, count is a number

CallResultWhen nothing matches
find.first, update.first, delete.firstRecord | nullnull
find.unique, update.unique, delete.uniqueRecordthrows AvNotFoundError (A2003)
find.manyreadonly Record[][]
find.count, create.count, update.count, delete.countnumber0
create.one, upsert.uniqueRecordnot applicable
create.many, update.manyreadonly Record[][]

The type system follows: a first result must be checked before use, a unique result need not.

Code
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:

ClassTypical codes
AvValidationErrorA2004, A2007, A2009 (with cause.issues, each with a V code and a path)
AvNotFoundErrorA2003
AvConflictErrorA2008, A2013, A2014
AvContractMismatchErrorA2005
AvAuthErrorA4000, A4001, A4002
AvInternalErrorA3000, A3001, A3002, A3003, A3004
AvProtocolErrorA2000, A2001, A2002, A2006, A2010, A2011, A2012 (a request the server could not route or interpret)
AvTransportErrorno response, or a response that is not a framework envelope (not an AvError; it has no code)
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.

Code
const n = await framework.application.User.find.count({});   // number

See also