Skip to content

Error Handling

Effect GraphQL provides structured error types that integrate seamlessly with GraphQL’s error system and Effect’s error channel.

Effect GraphQL provides several error types out of the box:

import {
GraphQLError,
ValidationError,
AuthorizationError,
NotFoundError,
} from "@effect-gql/core"

The base error type with support for extensions:

new GraphQLError({
message: "Something went wrong",
extensions: {
code: "INTERNAL_ERROR",
timestamp: new Date().toISOString(),
},
})

For input validation failures:

new ValidationError({
message: "Invalid email format",
field: "email",
extensions: {
code: "VALIDATION_ERROR",
},
})

For access control violations:

new AuthorizationError({
message: "You don't have permission to access this resource",
extensions: {
code: "FORBIDDEN",
requiredRole: "ADMIN",
},
})

For missing resources:

new NotFoundError({
message: "User not found",
resourceType: "User",
resourceId: "123",
extensions: {
code: "NOT_FOUND",
},
})

Return errors using Effect.fail:

.query("user", {
type: UserSchema,
args: S.Struct({ id: S.String }),
resolve: (args) => Effect.gen(function* () {
const userService = yield* UserService
const user = yield* userService.getById(args.id)
if (!user) {
return yield* Effect.fail(
new NotFoundError({
message: `User with id ${args.id} not found`,
resourceType: "User",
resourceId: args.id,
})
)
}
return user
}),
})

Transform errors as they propagate using Effect combinators:

resolve: (args) => Effect.gen(function* () {
const db = yield* DatabaseService
return yield* db.query(`SELECT * FROM users WHERE id = $1`, [args.id])
}).pipe(
Effect.catchTag("DatabaseError", (error) =>
Effect.fail(new GraphQLError({
message: "Database temporarily unavailable",
extensions: { code: "SERVICE_UNAVAILABLE" },
}))
)
)
resolve: (args) => Effect.gen(function* () {
// ... resolver logic
}).pipe(
Effect.mapError((error) => {
if (error instanceof NotFoundError) {
return error // Keep as-is
}
// Wrap unknown errors
return new GraphQLError({
message: "An unexpected error occurred",
extensions: { code: "INTERNAL_ERROR" },
})
})
)
resolve: (args) => Effect.gen(function* () {
const cache = yield* CacheService
return yield* cache.get(args.key)
}).pipe(
Effect.catchAll(() =>
// Fallback to database if cache fails
Effect.gen(function* () {
const db = yield* DatabaseService
return yield* db.get(args.key)
})
)
)

Create your own error types using Effect’s Data.TaggedError:

import { Data } from "effect"
class RateLimitError extends Data.TaggedError("RateLimitError")<{
readonly message: string
readonly retryAfter: number
}> {}
class QuotaExceededError extends Data.TaggedError("QuotaExceededError")<{
readonly message: string
readonly quota: number
readonly used: number
}> {}

Use in resolvers:

resolve: (args) => Effect.gen(function* () {
const rateLimiter = yield* RateLimiterService
const allowed = yield* rateLimiter.check(args.userId)
if (!allowed.ok) {
return yield* Effect.fail(
new RateLimitError({
message: "Too many requests",
retryAfter: allowed.retryAfter,
})
)
}
// Continue with request...
})

Arguments are validated automatically using Effect Schema. Validation errors are returned as GraphQL errors:

.mutation("createUser", {
type: UserSchema,
args: S.Struct({
email: S.String.pipe(S.pattern(/@/)),
age: S.Int.pipe(S.positive()),
}),
resolve: (args) => {
// This only runs if validation passes
return Effect.succeed({ ... })
},
})

If validation fails, the client receives:

{
"errors": [
{
"message": "Invalid argument: email must match pattern /@/",
"extensions": {
"code": "VALIDATION_ERROR"
}
}
]
}

Errors are formatted according to the GraphQL spec:

{
"data": null,
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"resourceType": "User",
"resourceId": "123"
}
}
]
}

GraphQL supports partial responses where some fields succeed while others fail:

.query("dashboard", {
type: DashboardSchema,
resolve: () => Effect.gen(function* () {
const userService = yield* UserService
const statsService = yield* StatsService
// If stats fail, we still want user data
const [user, stats] = yield* Effect.all([
userService.getCurrentUser(),
statsService.getDashboardStats().pipe(
Effect.catchAll(() => Effect.succeed(null))
),
])
return { user, stats }
}),
})

When using the HTTP router, you can configure a global error handler for uncaught errors. This handles errors that occur outside of normal GraphQL execution (e.g., malformed requests, server errors).

By default, uncaught errors return a 500 Internal Server Error:

{
"errors": [
{
"message": "An error occurred processing your request"
}
]
}

In non-production environments, the full error is logged for debugging.

Provide a custom error handler to control the response:

import { makeGraphQLRouter, type ErrorHandler } from "@effect-gql/core/server"
import { HttpServerResponse } from "@effect/platform"
import { Cause, Effect } from "effect"
const customErrorHandler: ErrorHandler = (cause) =>
Effect.gen(function* () {
// Log the error
yield* Effect.logError("GraphQL request failed", cause)
// Check for specific error types
const failure = Cause.failureOption(cause)
if (failure._tag === "Some" && failure.value instanceof RateLimitError) {
return yield* HttpServerResponse.json(
{
errors: [{ message: "Rate limit exceeded", extensions: { code: "RATE_LIMITED" } }],
},
{ status: 429 }
)
}
// Default error response
return yield* HttpServerResponse.json(
{
errors: [{ message: "Internal server error" }],
},
{ status: 500 }
)
}).pipe(Effect.orDie)
const router = makeGraphQLRouter(schema, serviceLayer, {
errorHandler: customErrorHandler,
})

The error handler receives an Effect Cause and must return an HttpServerResponse:

type ErrorHandler = (
cause: Cause.Cause<unknown>
) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>

The global error handler catches errors that escape normal GraphQL error handling:

  • Malformed JSON in request body
  • Missing required fields in request
  • Errors during request parsing
  • Uncaught exceptions in resolver execution

You can import and customize the default handler:

import { defaultErrorHandler } from "@effect-gql/core/server"
// Use as-is
const router = makeGraphQLRouter(schema, serviceLayer, {
errorHandler: defaultErrorHandler,
})
// Or wrap it
const loggingErrorHandler: ErrorHandler = (cause) =>
Effect.gen(function* () {
yield* Effect.logError("Unhandled error in GraphQL", cause)
return yield* defaultErrorHandler(cause)
})

Use specific error types rather than generic ones:

// Good
new NotFoundError({ message: "User not found", resourceType: "User" })
// Less helpful
new GraphQLError({ message: "Not found" })

Add relevant information to error extensions:

new AuthorizationError({
message: "Access denied",
extensions: {
code: "FORBIDDEN",
resource: "admin/users",
requiredRole: "ADMIN",
userRole: currentUser.role,
},
})

Transform internal errors before returning to clients:

Effect.catchAll((error) => {
// Log the full error internally
console.error("Internal error:", error)
// Return sanitized error to client
return Effect.fail(new GraphQLError({
message: "An error occurred processing your request",
extensions: { code: "INTERNAL_ERROR" },
}))
})

Create error handling layers for different concerns:

const withAuthErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchTag("AuthError", (e) =>
Effect.fail(new AuthorizationError({ message: e.message }))
)
)
const withDatabaseErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchTag("DatabaseError", (e) =>
Effect.fail(new GraphQLError({
message: "Database error",
extensions: { code: "DATABASE_ERROR" },
}))
)
)
// Use in resolver
resolve: (args) => myLogic(args).pipe(
withAuthErrors,
withDatabaseErrors,
)