Skip to content

GraphQL Types & Schema Design — Complete Guide with SDL Examples

DodaTech Updated Jun 6, 2026 7 min read

The GraphQL type system defines your API’s shape — every field, relationship, and argument is described in the schema using Schema Definition Language (SDL).

What You’ll Learn

  • Object types, scalars, and custom scalars
  • Enums, interfaces, and union types
  • Input types for mutations and arguments
  • Non-null and list modifiers
  • Schema design patterns and best practices

Why Types and Schema Matter

The schema is the contract between client and server. A well-designed schema makes your API intuitive, evolvable, and self-documenting. DodaTech’s Durga Antivirus Pro GraphQL schema defines types for Device, Threat, Scan, User, and Alert — each with clear relationships that let the dashboard team build UIs without backend coordination.

    flowchart LR
    A["SDL Schema"] --> B["Object Types\n(User, Device)"]
    A --> C["Scalars\n(String, Int, ID)"]
    A --> D["Enums\n(Severity, Status)"]
    A --> E["Interfaces\n(Node)"]
    A --> F["Input Types\n(CreateDeviceInput)"]
    style A fill:#dbeafe,stroke:#2563eb
  
Prerequisites: Familiarity with GraphQL and REST concepts.

Object Types

Object types are the most common building block. They represent entities with multiple fields:

type Device {
  id: ID!
  name: String!
  os: String!
  version: String
  lastScan: DateTime
  user: User!
  threats: [Threat!]!
}

Each field has a type (String, ID, DateTime) and optional modifiers (! for non-null, [] for lists).

Scalar Types

Built-in Scalars

ScalarDescriptionExample
StringUTF-8 text"Office-PC"
Int32-bit integer42
FloatDouble-precision3.14
BooleanTrue or falsetrue
IDUnique identifier"dev-001"

Custom Scalars

Custom scalars add validation for specialized data:

scalar DateTime
scalar JSON
scalar EmailAddress
scalar URL
const { GraphQLScalarType, Kind } = require('graphql');

const dateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO 8601 date-time string',
  serialize(value) { return value instanceof Date ? value.toISOString() : value; },
  parseValue(value) { return new Date(value); },
  parseLiteral(ast) {
    return ast.kind === Kind.STRING ? new Date(ast.value) : null;
  },
});

Why custom scalars? They validate data at the API boundary, ensuring every DateTime field is a valid ISO 8601 string and every EmailAddress is a valid email.

Enums

Enums restrict a field to a fixed set of values:

enum Severity {
  LOW
  MEDIUM
  HIGH
  CRITICAL
}

enum DeviceStatus {
  ONLINE
  OFFLINE
  QUARANTINED
  DELETED
}

Usage:

type Threat {
  severity: Severity!
  status: ThreatStatus!
}

# Query with enum argument
query CriticalThreats {
  threats(severity: CRITICAL) { id name }
}

Enums are more explicit than strings — they prevent typos, enable autocomplete in GraphiQL, and make the schema self-documenting.

Non-Null and List Modifiers

Modifiers control whether a field can be null or return multiple values:

SyntaxMeaningCan be null?
StringNullable stringYes
String!Non-null stringNo
[String]Nullable list of nullable stringsYes (list and items)
[String!]Nullable list of non-null stringsList yes, items no
[String]!Non-null list of nullable stringsList no, items yes
[String!]!Non-null list of non-null stringsNeither

Best practice: Use [String!]! for most list fields — the list always exists but may be empty, and items are never null.

Interfaces

Interfaces define shared fields that multiple types can implement:

interface Node {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Device implements Node {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  name: String!
  os: String!
}

type Threat implements Node {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  name: String!
  severity: Severity!
}

Queries can return interface types and use inline fragments to access type-specific fields:

query Search {
  search(query: "Emotet") {
    ... on Device { name os }
    ... on Threat { name severity }
  }
}

Union Types

Unions represent a field that can be one of several types (no shared fields required):

union SearchResult = Device | Threat | User

type Query {
  search(query: String!): [SearchResult!]!
}

Use inline fragments to handle each type:

query {
  search(query: "critical") {
    __typename  # Always check which type was returned
    ... on Device { id name os }
    ... on Threat { id name severity detectedAt }
    ... on User { id email }
  }
}

Input Types

Input types pass complex objects as arguments to mutations:

input CreateDeviceInput {
  name: String!
  os: String!
  version: String
  userId: ID!
}

input UpdateDeviceInput {
  name: String
  os: String
  version: String
}

type Mutation {
  createDevice(input: CreateDeviceInput!): Device!
  updateDevice(id: ID!, input: UpdateDeviceInput!): Device!
}

Key difference: Object types use type and can have resolvers. Input types use input and cannot have resolvers — they’re pure data structures for arguments.

Common Mistakes

1. Overusing Nullable Fields

Marking everything as nullable (String instead of String!) makes clients handle null checks everywhere. Be intentional — if a field always has a value, make it non-null.

2. Exposing Database IDs

Exposing auto-increment database IDs (userId: 123) is a security risk. Use opaque identifiers (UUIDs or GraphQL’s ID scalar).

3. Not Using Input Types

Passing 10+ arguments directly to a mutation is unwieldy. Use input types to group related fields.

4. Ignoring Deprecation

Instead of breaking changes, deprecate fields:

type Device {
  os: String
  operatingSystem: String @deprecated(reason: "Use 'os' instead")
}

5. Making Enums Too Generic

Status { ACTIVE, INACTIVE, PENDING } is too vague for reuse across devices, threats, and users. Create specific enums for each domain.

Practice Questions

  1. What is the difference between type and input in SDL?
  2. When would you use an interface vs a union?
  3. What does [String!]! mean?
  4. Why use custom scalars like DateTime instead of String?
  5. How do you deprecate a field in GraphQL?

Answers:

  1. type defines output objects with resolvers. input defines argument structures without resolvers.
  2. Use interfaces when types share common fields. Use unions when types are completely different but can appear in the same field.
  3. A non-nullable list containing non-nullable strings. The list is always present (possibly empty), and every item is guaranteed to be a string.
  4. Custom scalars validate format at the API boundary, provide better documentation, and enable tooling (autocomplete, validation).
  5. Add @deprecated(reason: "Use fieldName instead") directive to the field.

Challenge: Design a GraphQL schema for Durga Antivirus Pro’s alert system. Include types for Alert (id, message, severity, device, createdAt), AlertSeverity enum, an AlertConnection type for pagination, input types for creating and updating alerts, and queries/mutations for listing, creating, and dismissing alerts.

FAQ

Can I change a non-null field to nullable?
: Yes — this is a backward-compatible change. Changing nullable to non-null is a breaking change because existing queries might not request the field and would break if the value is suddenly required.
What happens if a resolver returns null for a non-null field?
: GraphQL propagates the null upward to the parent. If a non-null field on a non-null type returns null, the entire parent becomes null. This is called “null propagation.”
Should I use ID or String for identifiers?
: Use ID for identifiers — it serializes as a string but signals to tools (Apollo, Relay) that this field is a unique identifier. Apollo Client uses ID fields for cache normalization.
What is the maximum schema size?
: There’s no hard limit, but large schemas (1000+ types) can slow down schema validation and tooling. Use schema modularization (merge type definitions from multiple files) and consider federated architectures for very large schemas.

Try It Yourself

Create a schema with multiple types and test it:

const { ApolloServer, gql } = require('apollo-server');

const typeDefs = gql`
  enum Severity { LOW MEDIUM HIGH CRITICAL }
  type Threat { id: ID! name: String! severity: Severity! }
  type Query { threats: [Threat!]! }
`;

const resolvers = {
  Query: { threats: () => [
    { id: '1', name: 'Emotet', severity: 'CRITICAL' },
    { id: '2', name: 'Adware', severity: 'LOW' },
  ]},
};

new ApolloServer({ typeDefs, resolvers }).listen(4000);

What’s Next

TopicDescription
Queries & ResolversResolver functions, arguments, and context
Mutations & Input TypesCreating and modifying data
GraphQL ArchitectureServer setup, batching, and caching
GraphQL IntroductionReview core concepts

What’s Next

Congratulations on completing this Graphql Types Schema tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro