GraphQL errors
async-graphql Result, extensions, codes — and how a ServiceError lands on the wire as a typed GraphQL error.
A resolver returns T or async_graphql::Result<T>; an Err
short-circuits with a GraphQL error envelope alongside data. The
message goes on errors[].message; structured data goes on
errors[].extensions. The framework owns the mapping for its shared
error types — ServiceError from nest-rs-seaorm, CredentialError
and TokenError from nest-rs-authn — so a feature returns its
service result and the wire shape just works.
The smallest error
Section titled “The smallest error”use async_graphql::{Error, Result};use nest_rs::graphql::{operations, resolver};
#[resolver]pub struct PostsResolver;
#[operations]impl PostsResolver { #[query] #[public] async fn post(&self, id: String) -> Result<String> { if id.is_empty() { return Err(Error::new("id must not be empty")); } Ok(format!("post {id}")) }}On the wire:
{ "data": null, "errors": [ { "message": "id must not be empty", "path": ["post"], "locations": [{ "line": 1, "column": 3 }] } ]}Extensions and codes
Section titled “Extensions and codes”extensions carry structured data — a stable code clients can branch
on without parsing the message:
use async_graphql::{Error, Result};
#[query]#[public]async fn post(&self, id: String) -> Result<String> { if id.is_empty() { return Err(Error::new("id must not be empty").extend_with(|_, e| { e.set("code", "INVALID_ARGUMENT"); })); } Ok(format!("post {id}"))}{ "errors": [ { "message": "id must not be empty", "extensions": { "code": "INVALID_ARGUMENT" }, "path": ["post"] } ]}Codes are free-form strings — pick a vocabulary and stick to it. A
typical set: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND,
INVALID_ARGUMENT, INTERNAL. The framework’s own by-id binding uses the same
vocabulary: a bind argument that is not a UUID v7 — malformed, or well-formed
but a different version — answers "id must be a UUID v7" with
extensions.code = "INVALID_ARGUMENT", the GraphQL twin of the HTTP path’s
400. The Apollo spec calls out these names
and clients converge on them.
Mapping ServiceError
Section titled “Mapping ServiceError”A CrudService method returns Result<T, ServiceError> — the shared
framework error covering the two failure modes every service has:
pub enum ServiceError { Validation(ValidationErrors), Db(DbErr),}A resolver propagates that into a GraphQL Result<T> with ?:
use async_graphql::Result;use nest_rs::authz::Read;use nest_rs::graphql::{operations, resolver};
#[operations]impl PostsResolver { #[query] #[authorize(Read, posts::Entity)] async fn post(&self, id: String) -> Result<Post> { Ok(self.svc.find_by_id(&id).await?) }}ServiceError already implements Into<async_graphql::Error>
through async-graphql’s From<E: Display> blanket — the Display
side stays wire-safe ("database error" for the Db variant) so a
SQL fragment never leaks. The Validation variant forwards through
validator’s structured error.
For a richer envelope, wrap the conversion to attach a code:
use async_graphql::{Error, ErrorExtensions, Result};use nest_rs::seaorm::ServiceError;
fn graphql_from_service(err: ServiceError) -> Error { let code = match &err { ServiceError::Validation(_) => "INVALID_ARGUMENT", ServiceError::Db(_) => "INTERNAL", }; Error::new(err.to_string()).extend_with(|_, e| e.set("code", code))}
#[query]#[authorize(Read, posts::Entity)]async fn post(&self, id: String) -> Result<Post> { self.svc.find_by_id(&id).await.map_err(graphql_from_service)}Same shape for CredentialError (always UNAUTHENTICATED) and
TokenError (UNAUTHENTICATED with the RFC 6749 error code in a
nested field if you need it).
An error the client is not owed
Section titled “An error the client is not owed”ServiceError is wire-safe by construction — its Db variant displays as
"database error", never the DbErr underneath. A feature’s own error
type has no such discipline imposed on it, and a Display that wraps a
driver error carries SQL, column names and sometimes row values into the
error frame.
Opaque is the seam for that, and it reads the same on every client-facing
transport — HTTP, GraphQL, WebSockets and MCP:
use nest_rs::graphql::Opaque;
#[query]#[authorize(Read, posts::Entity)]async fn post(&self, id: String) -> Result<Post> { Ok(self.svc.find_by_id(&id).await.opaque()?)}.opaque()? logs the real error at error on nest_rs::graphql and puts
a constant message on the wire, under the same INTERNAL code an internal
denial carries — so a client cannot tell an unexpected failure from a
refusal it was owed no explanation for.
Reach for it on a failure that is none of the caller’s business. A validation rejection or an authorization denial is the opposite case: those exist to be read, and travel as themselves.
Authorization denials
Section titled “Authorization denials”The GraphqlAbilityBridge from nest-rs-authz’s graphql feature
turns an Ability refusal into a FORBIDDEN-coded error before the
The same bridge answers a missing or invalid principal with
UNAUTHENTICATED, since it runs authentication in band before the
operation.
The handler doesn’t write the mapping; it just declares
#[authorize(Action, Entity)]. See Security for
the full chain.
Option<T> vs error
Section titled “Option<T> vs error”Option<T> is the right return when an absent value is a normal
outcome (looked something up, found nothing). Reserve errors for
abnormal outcomes (validation failed, DB unreachable, principal
refused). On the wire:
Option<T>returningNone→"data": { "post": null }, noerrorsblock.Result<T>returningErr→"errors": [...], the field’s value isnull.
The bind helper follows this rule: bind::<Read, UsersService>(ctx, &id).await? returns Result<Option<User>> — None means absent
(404 equivalent), Err means denied or broken.
#[query]#[authorize(Read, UserEntity)]async fn user(&self, ctx: &Context<'_>, id: String) -> Result<Option<User>> { Ok(bind::<Read, UsersService>(ctx, &id) .await? .as_ref() .map(User::from))}Multiple errors in one response
Section titled “Multiple errors in one response”GraphQL allows several errors per response. A query selecting two
fields, each failing, gets two errors[] entries — async-graphql
collects them automatically across resolver calls. There is no
“first error wins” semantic; clients see the full set with path
pointing at each failed field.
This matters most for partial-success shapes (one ok, one denied)
where the client wants the available data plus a clear refusal on the
rest. The framework does not need any code for this — return the
Result from each #[field_resolver] and async-graphql does the
gathering.
Going further
Section titled “Going further”- Queries and mutations — the
Result<T>return shape at the top level. - Security — the guard chain that decides
UNAUTHENTICATED/FORBIDDENbefore a resolver runs. - Database — the service contract that
emits
ServiceError.