Object Types
Object types are the bread and butter of a GraphQL schema. Each one declares a set of named, typed fields. Fields can return scalars, other object types, or lists — arbitrarily nested. Resolvers attach behavior.
Schema, resolvers, lists, nullability
EXAMPLE
// 1) Define object types in SDL
const typeDefs = `#graphql
type Query {
me: User
user(id: ID!): User
posts(authorId: ID, first: Int = 20): [Post!]!
}
type User {
id: ID!
email: String! # required
name: String # optional
role: Role!
createdAt: DateTime!
posts: [Post!]! # required list of required posts
avatarUrl: String
}
type Post {
id: ID!
title: String!
body: String!
author: User! # author is always present
tags: [String!]!
publishedAt: DateTime
comments(first: Int = 50): [Comment!]!
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
}
enum Role { GUEST USER EDITOR ADMIN }
scalar DateTime
`;
// 2) Nullability — read carefully
// String — nullable string
// String! — non-null string (the field MUST return a value)
// [String] — nullable list of nullable strings
// [String!] — nullable list of non-null strings
// [String!]! — non-null list of non-null strings (most common shape for lists)
//
// Rule of thumb: lists are usually [T!]! — neither null itself nor containing nulls.
// 3) Resolvers — one per field, optional if shape matches
const resolvers = {
Query: {
me: (_p, _a, ctx) => ctx.user || null,
user: (_p, { id }, ctx) => ctx.dataSources.users.byId(id),
posts: (_p, { authorId, first }, ctx) =>
ctx.dataSources.posts.list({ authorId, first }),
},
User: {
// Default resolver returns user[fieldName]; override for derived fields.
posts: (user, _a, ctx) => ctx.dataSources.posts.byAuthor(user.id),
avatarUrl: (user) =>
user.avatarKey ? `https://cdn.example.com/${user.avatarKey}` : null,
},
Post: {
author: (post, _a, ctx) => ctx.dataSources.users.byId(post.authorId),
comments: (post, { first }, ctx) =>
ctx.dataSources.comments.byPost(post.id, first),
},
Comment: {
author: (c, _a, ctx) => ctx.dataSources.users.byId(c.authorId),
post: (c, _a, ctx) => ctx.dataSources.posts.byId(c.postId),
},
};
// 4) Querying
`
query ProfilePage($id: ID!) {
user(id: $id) {
id
name
role
posts {
id
title
publishedAt
comments(first: 3) {
id
body
author { name }
}
}
}
}
`;
// 5) Avoiding N+1 — DataLoader batches per request
import DataLoader from 'dataloader';
function createUserLoader(db) {
return new DataLoader(async (ids) => {
const rows = await db.user.findMany({ where: { id: { in: ids } } });
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) || null); // order MUST match input
});
}
// Context — created per request
async function context({ req }) {
return {
user: await authenticate(req),
loaders: { user: createUserLoader(db) },
};
}
// Use in resolvers
const resolvers2 = {
Post: {
author: (post, _a, ctx) => ctx.loaders.user.load(post.authorId),
},
};
// 6) Interfaces — shared fields across object types
const typeDefs2 = `#graphql
interface Node { id: ID! }
type User implements Node { id: ID! email: String! }
type Post implements Node { id: ID! title: String! }
type Query { node(id: ID!): Node }
`;
const resolvers3 = {
Node: {
__resolveType(obj) {
if (obj.email) return 'User';
if (obj.title) return 'Post';
return null;
},
},
};
// 7) Unions — mutually exclusive object types
const typeDefs3 = `#graphql
union SearchResult = User | Post | Comment
type Query { search(q: String!): [SearchResult!]! }
`;
// 8) Field arguments + default values
type Query {
posts(first: Int = 20, after: String, status: PostStatus = PUBLISHED): [Post!]!
}
// 9) Computed fields
const resolvers4 = {
User: {
displayName: (u) => u.name || u.email.split('@')[0],
isAdmin: (u) => u.role === 'ADMIN',
},
};
// 10) Errors and partial responses
// A non-null resolver that throws nulls out the parent until a nullable ancestor.
// To return partial data without crashing the query, mark error-prone fields nullable.
// 11) Common bugs
// • Returning Date object for a DateTime scalar without a custom scalar resolver
// • [Post!]! resolver returns null on error → whole field collapses
// • Forgetting __resolveType on interfaces/unions → 'cannot determine type'
// • N+1 queries — Post.author hits DB per post; always use DataLoader
// • Args named differently in SDL vs resolver — resolver param is destructured from args
// • Treating type defs as JS strings without the GraphQL highlight comment — lose tooling
Why it matters
Design types around the data shape your clients want, not your database’s. Make lists [T!]!, gate behavior at the field level rather than smuggling auth into a wrapper, and put a DataLoader in context to keep child-field resolvers from turning every query into a fan-out.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…