Bài 25.2: Typing GraphQL queries và mutations

Chào mừng bạn quay trở lại với chuỗi bài blog về Lập trình Web Front-end!

Trong hành trình xây dựng các ứng dụng hiện đại, việc giao tiếp với back-end là không thể thiếu. GraphQL đã trở thành một lựa chọn phổ biến nhờ khả năng linh hoạt trong việc truy vấn dữ liệu. Tuy nhiên, khi kết hợp GraphQL với TypeScript, một câu hỏi quan trọng đặt ra là: Làm thế nào để đảm bảo dữ liệu nhận về từ GraphQL API khớp với kiểu dữ liệu mà code TypeScript của chúng ta mong đợi?

Đây chính là lúc khái niệm Typing GraphQL queries và mutations trở nên cực kỳ quan trọng.

Vấn đề khi làm việc với GraphQL "phiên bản untyped"

Hãy tưởng tượng bạn đang viết một ứng dụng front-end sử dụng TypeScript và gọi API GraphQL. Bạn gửi một query để lấy thông tin người dùng:

query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    // Có thể có thêm trường address
  }
}

Sau đó, trong code TypeScript, bạn nhận được dữ liệu này. Nếu không có typing, bạn sẽ thao tác với đối tượng data như thế này:

// Ví dụ đơn giản không dùng framework/library cụ thể
// Giả sử data là kết quả trả về từ API
const userData = fetchDataFromGraphql(/* query, variables */);

// Bạn phải giả định cấu trúc dữ liệu
console.log(userData.user.name);
console.log(userData.user.email);

// Điều gì xảy ra nếu user không tồn tại? Hoặc email bị null?
// Điều gì xảy ra nếu backend thay đổi query, bỏ trường email đi?
// TypeScript sẽ không biết điều đó cho đến khi bạn chạy code và gặp lỗi runtime!

Cách làm này tiềm ẩn nhiều rủi ro:

  1. Thiếu an toàn kiểu dữ liệu: Bạn không có cách nào đảm bảo rằng userData.user luôn tồn tại, hoặc userData.user.name luôn là string. Lỗi có thể xảy ra khi chạy (runtime errors), rất khó debug.
  2. Không có Autocompletion: Editor của bạn không "hiểu" cấu trúc của userData, dẫn đến việc thiếu gợi ý mã (autocompletion), làm giảm năng suất code.
  3. Khó Refactor: Nếu bạn thay đổi query GraphQL (thêm/bớt trường), bạn phải nhớ đi chỉnh sửa tất cả các đoạn code TypeScript sử dụng kết quả của query đó. Rất dễ bỏ sót.
  4. Code kém rõ ràng: Nhìn vào code, khó biết chính xác cấu trúc dữ liệu mà bạn đang làm việc là gì.
Giải pháp: Tự động sinh mã (Code Generation) cho GraphQL Types

Cách tiếp cận hiện đại và hiệu quả nhất để giải quyết vấn đề trên là sử dụng các công cụ tự động sinh mã (code generation). Những công cụ này sẽ:

  1. Đọc GraphQL Schema của bạn (định nghĩa cấu trúc dữ liệu và các phép toán có sẵn).
  2. Đọc các GraphQL Document của bạn (các file .graphql hoặc string chứa queries, mutations, subscriptions mà bạn viết).
  3. Dựa trên Schema và các Document, chúng sẽ sinh ra các file TypeScript chứa definition về kiểu dữ liệu tương ứng với chính xác những gì bạn đã truy vấn hoặc thao tác.

Công cụ phổ biến và mạnh mẽ nhất cho việc này là graphql-codegen.

graphql-codegen: Người bạn đồng hành của TypeScript & GraphQL

graphql-codegen là một thư viện dòng lệnh linh hoạt, có thể được cấu hình với nhiều plugin khác nhau để sinh ra đủ loại mã, từ TypeScript types cơ bản đến các React Hooks, Angular Services, v.v., tất cả đều được typing hoàn chỉnh.

Cách thức hoạt động cơ bản:

  1. Cài đặt: Bạn cài đặt graphql-codegen/cli cùng với các plugin cần thiết (ví dụ: @graphql-codegen/typescript, @graphql-codegen/typescript-operations).
  2. Cấu hình: Tạo một file cấu hình (thường là codegen.yml hoặc codegen.ts) để chỉ định:
    • Schema: Nguồn của Schema GraphQL (URL của API server, hoặc file local).
    • Documents: Đường dẫn đến các file chứa queries/mutations/subscriptions của bạn.
    • Generates: Các plugin muốn sử dụng và đường dẫn file output.
  3. Chạy: Chạy lệnh graphql-codegen từ terminal.
  4. Sử dụng: Nhập (import) các kiểu dữ liệu hoặc các hàm/hooks đã được sinh ra vào code TypeScript của bạn và sử dụng chúng.
Ví dụ Minh Họa: Typing Query

Giả sử bạn có file src/graphql/queries/user.graphql với nội dung:

# src/graphql/queries/user.graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
  }
}

File cấu hình codegen.yml có thể trông như thế này (đơn giản hóa):

# codegen.yml
schema: http://localhost:4000/graphql # Thay bằng URL GraphQL API của bạn
documents: "src/**/*.graphql" # Tìm tất cả file .graphql trong src
generates:
  src/graphql/generated.ts: # File output
    plugins:
      - typescript # Sinh ra các kiểu dữ liệu cơ bản từ schema
      - typescript-operations # Sinh ra các kiểu dữ liệu dựa trên các documents (queries/mutations)
    config:
      skipTypename: true # Tùy chọn, bỏ qua trường __typename

Khi bạn chạy lệnh graphql-codegen, nó sẽ đọc schema, đọc file user.graphql và sinh ra file src/graphql/generated.ts. Nội dung của file này sẽ bao gồm các kiểu dữ liệu như:

// src/graphql/generated.ts (Được sinh ra tự động - Đơn giản hóa)

export type User = {
  id: string;
  name: string;
  email: string;
};

export type GetUserQueryVariables = {
  id: string;
};

export type GetUserQuery = {
  user?: User | null;
};

// Ngoài ra còn có DocumentNode cho query GetUser
// Ví dụ (Apollo Client): import { gql } from '@apollo/client';
// export const GetUserDocument = gql`...`;

Bây giờ, trong component React hoặc bất kỳ file TypeScript nào khác, bạn có thể sử dụng các kiểu dữ liệu này:

// Ví dụ sử dụng Apollo Client và các kiểu dữ liệu được sinh ra
import { useQuery } from '@apollo/client';
// Nhập các kiểu dữ liệu và document node từ file đã sinh
import { GetUserQuery, GetUserQueryVariables, GetUserDocument } from './graphql/generated';

interface UserProfileProps {
  userId: string;
}

function UserProfile({ userId }: UserProfileProps) {
  // useQuery giờ đây biết chính xác kiểu dữ liệu của 'data' và 'variables'
  const { data, loading, error } = useQuery<GetUserQuery, GetUserQueryVariables>(GetUserDocument, {
    variables: { id: userId },
  });

  if (loading) return <p>Đang tải...</p>;
  if (error) return <p> lỗi xảy ra :(</p>;

  // data?.user được typed là User | null | undefined
  const user = data?.user;

  // Với kiểu dữ liệu rõ ràng, bạn có:
  // 1. Autocompletion: Gõ user. sẽ hiển thị id, name, email.
  // 2. Type Safety: Nếu bạn gõ user.address (mà address không có trong query), TypeScript sẽ báo lỗi ngay lúc biên dịch!
  // 3. Code rõ ràng hơn: Dễ dàng biết data.user có cấu trúc như thế nào.

  if (!user) {
    return <p>Không tìm thấy người dùng.</p>;
  }

  return (
    <div>
      <h2>{user.name}</h2> {/* user.name được typed  string */}
      <p>Email: {user.email}</p> {/* user.email được typed  string */}
    </div>
  );
}

Sự khác biệt là rất lớn! Bạn có sự an toàn và hỗ trợ từ TypeScript ngay từ khi viết code, thay vì chờ đến lúc chạy.

Ví dụ Minh Họa: Typing Mutation

Tương tự với mutations. Giả sử bạn có file src/graphql/mutations/product.graphql:

# src/graphql/mutations/product.graphql
mutation CreateProduct($input: CreateProductInput!) {
  createProduct(input: $input) {
    id
    name
    price
  }
}

Và Schema của bạn định nghĩa kiểu CreateProductInput.

graphql-codegen sẽ sinh ra các kiểu dữ liệu tương ứng:

// src/graphql/generated.ts (Được sinh ra tự động - Đơn giản hóa)

// Kiểu dữ liệu cho input variables
export type CreateProductInput = {
  name: string;
  price: number;
  description?: string | null;
};

// Kiểu dữ liệu cho kết quả mutation
export type Product = {
  id: string;
  name: string;
  price: number;
};

export type CreateProductMutationVariables = {
  input: CreateProductInput;
};

export type CreateProductMutation = {
  createProduct?: Product | null;
};

// DocumentNode cho mutation CreateProduct
// export const CreateProductDocument = gql`...`;

Sử dụng các kiểu dữ liệu này trong code:

// Ví dụ sử dụng Apollo Client
import { useMutation } from '@apollo/client';
// Nhập các kiểu dữ liệu và document node
import {
  CreateProductMutation,
  CreateProductMutationVariables,
  CreateProductDocument,
  CreateProductInput // Kiểu dữ liệu cho input
} from './graphql/generated';

function NewProductForm() {
  const [createProduct, { data, loading, error }] = useMutation<
    CreateProductMutation,
    CreateProductMutationVariables
  >(CreateProductDocument);

  // Hàm xử lý khi submit form
  const handleSubmit = async (values: CreateProductInput) => {
    try {
      // TypeScript đảm bảo 'values' khớp với kiểu CreateProductInput
      const response = await createProduct({
        variables: { input: values },
      });
      console.log('Sản phẩm mới được tạo:', response.data?.createProduct); // data?.createProduct được typed là Product | null | undefined
    } catch (e) {
      console.error('Lỗi khi tạo sản phẩm:', e);
    }
  };

  // ... (phần code render form, lấy values từ input và gọi handleSubmit)
  return (
    <form onSubmit={/* ... */}>
      {/* Các input cho name, price, description */}
      <button type="submit">Tạo sản phẩm</button>
      {loading && <p>Đang tạo...</p>}
      {error && <p>Tạo sản phẩm thất bại.</p>}
    </form>
  );
}

Tương tự như query, việc typing mutation mang lại sự tin cậy cao hơn khi làm việc với input variables và xử lý kết quả trả về.

Lợi ích vượt trội

Việc áp dụng typing cho GraphQL queries và mutations mang lại những lợi ích không thể phủ nhận:

  • Độ tin cậy cao hơn: Giảm thiểu đáng kể các lỗi runtime liên quan đến dữ liệu. TypeScript kiểm tra sự khớp nối giữa code và cấu trúc dữ liệu GraphQL ngay tại thời điểm biên dịch.
  • Năng suất phát triển: Autocompletion và kiểm tra lỗi tại chỗ giúp bạn code nhanh hơn, tự tin hơn và ít phải nhảy qua lại giữa code và tài liệu API.
  • Dễ bảo trì: Khi Schema hoặc queries thay đổi, graphql-codegen sẽ sinh lại types mới. Nếu code của bạn không còn khớp với types mới, TypeScript sẽ báo lỗi, chỉ cho bạn chính xác những chỗ cần cập nhật. Điều này giúp việc refactoring trở nên an toàn và hiệu quả hơn rất nhiều.
  • Codebase rõ ràng: Kiểu dữ liệu tường minh giúp đồng đội dễ dàng hiểu cấu trúc dữ liệu mà mỗi phần của ứng dụng đang sử dụng.

Ngoài các plugin cơ bản typescripttypescript-operations, graphql-codegen còn có các plugin mạnh mẽ khác tích hợp sâu với các thư viện quản lý state GraphQL như Apollo Client (typescript-react-apollo), Relay (typescript-relay), RTK Query (typescript-rtk-query),... Các plugin này không chỉ sinh ra types mà còn cả các React Hooks (ví dụ: useGetUserQuery, useCreateProductMutation) đã được typing sẵn, giúp bạn bắt đầu sử dụng GraphQL trong framework của mình một cách nhanh chóng và an toàn nhất.

Tóm lại, nếu bạn đang sử dụng TypeScript và GraphQL, việc triển khai code generation để typing queries và mutations là một bước tiến thiết yếu để xây dựng các ứng dụng front-end mạnh mẽ, đáng tin cậy và dễ bảo trì. Đừng bỏ qua sức mạnh của việc kết hợp hai công nghệ tuyệt vời này!

Comments

There are no comments at the moment.