Bài 31.2: Typing custom authentication logic

Chào mừng các bạn đến với bài viết chuyên sâu về việc nâng cao chất lượng code khi xử lý các luồng xác thực người dùng (authentication) trong ứng dụng front-end, đặc biệt là khi bạn đang làm việc với TypeScript và các framework như React hay Next.js. Bài hôm nay sẽ tập trung vào cách sử dụng TypeScript để định kiểu (typing) cho logic xác thực tùy chỉnh (custom authentication logic) của bạn.

Khi xây dựng ứng dụng web, xác thực là một phần cực kỳ quan trọng. Đôi khi, chúng ta không sử dụng các thư viện xác thực "có sẵn" hoàn toàn, mà cần triển khai logic riêng để giao tiếp với API backend tùy chỉnh, quản lý trạng thái người dùng, xử lý token, v.v. Đây chính là "custom authentication logic".

Vậy tại sao chúng ta cần phải "typing" cho nó? Đơn giản là vì TypeScript mang lại sự an toàn và minh bạch. Logic xác thực thường liên quan đến dữ liệu nhạy cảm (thông tin người dùng), các trạng thái phức tạp (đang tải, lỗi, đã đăng nhập/chưa đăng nhập), và các luồng xử lý bất đồng bộ. Nếu không có typing, code của bạn dễ trở nên lộn xộn, khó hiểu, và tiềm ẩn rất nhiều lỗi runtime không đáng có.

Thách Thức Khi Không Typing Logic Xác Thực

Hãy tưởng tượng bạn có một object đại diện cho người dùng đã đăng nhập. Nếu bạn không định kiểu nó, nó có thể chỉ là một object hoặc any.

// JavaScript hoặc TypeScript KHÔNG có typing rõ ràng
let currentUser = null; // Hoặc có thể là một object

async function login(username, password) {
  // ... gọi API
  const response = await fetch('/api/login', { /* ... */ });
  const userData = await response.json(); // userData có cấu trúc gì?

  if (response.ok) {
    currentUser = userData; // Gán userData vào biến
    console.log(currentUser.name); // Có chắc chắn có thuộc tính 'name'?
    // ... lưu token, redirect, ...
  } else {
    // ... xử lý lỗi
  }
}

// Ở một component khác
function UserProfile() {
  // ... làm sao biết currentUser có tồn tại không?
  // ... làm sao biết currentUser có những thuộc tính gì?
  if (currentUser) {
    return <div>Chào mừng, {currentUser.displayName}!</div>; // displayLame? Hay name? Hay username?
  }
  return null;
}

Trong ví dụ trên:

  • Chúng ta không biết chắc chắn cấu trúc của userData trả về từ API.
  • Chúng ta không biết currentUser có những thuộc tính nào (name, displayName, username, email?).
  • Sẽ rất dễ gây ra lỗi runtime nếu truy cập một thuộc tính không tồn tại (TypeError: Cannot read properties of undefined (reading 'displayName')).
  • Việc debug và bảo trì code trở nên khó khăn vì thiếu thông tin rõ ràng về dữ liệu.

Đây chính là lúc TypeScript bước vào và giải cứu.

Sức Mạnh Của Typing Với TypeScript

TypeScript cho phép bạn định nghĩa rõ ràng hình dạng (shape) của dữ liệu. Khi áp dụng vào logic xác thực tùy chỉnh, bạn có thể:

  1. Định nghĩa cấu trúc dữ liệu người dùng: Bạn biết chính xác một object User trông như thế nào.
  2. Định nghĩa cấu trúc dữ liệu yêu cầu/phản hồi API: Bạn biết dữ liệu gửi đi khi login/register cần gì và dữ liệu nhận về sẽ có gì.
  3. Định nghĩa trạng thái xác thực: Bạn biết trạng thái hiện tại (đang tải, đã đăng nhập, lỗi) được biểu diễn như thế nào.
  4. Định nghĩa chữ ký hàm (function signatures): Bạn biết các hàm login, logout nhận tham số gì và trả về kiểu dữ liệu gì.

Điều này tạo ra một bức tường thành vững chắc, giúp trình biên dịch TypeScript bắt lỗi ngay trước khi bạn chạy ứng dụng, thay vì gặp lỗi lúc runtime.

Các Bước Typing Custom Authentication Logic

Chúng ta sẽ đi qua các bước cơ bản để áp dụng TypeScript vào logic xác thực:

1. Định Nghĩa Kiểu Dữ Liệu Người Dùng (User Data Type)

Đây là kiểu dữ liệu cốt lõi. Bạn cần xác định những thông tin nào về người dùng sẽ được lưu trữ và sử dụng ở phía client.

// src/types/auth.ts hoặc cùng file logic auth
export interface User {
  id: string;
  username: string;
  email: string;
  // Thêm các thuộc tính khác mà ứng dụng của bạn cần hiển thị hoặc sử dụng
  // ví dụ: avatarUrl?: string; role?: 'admin' | 'user';
}

// Bạn cũng có thể định nghĩa kiểu cho dữ liệu dùng để đăng nhập/đăng ký
export interface LoginCredentials {
  username: string;
  password: string;
}

export interface RegisterData extends LoginCredentials {
  email: string;
  // Thêm các trường khác cần thiết cho đăng ký
}
  • Giải thích: Chúng ta sử dụng interface (hoặc type) để mô tả cấu trúc của một object User. Mỗi thuộc tính có tên và kiểu dữ liệu rõ ràng (string). Ký hiệu ? sau tên thuộc tính (avatarUrl?) chỉ ra rằng thuộc tính này là tùy chọn (optional). Chúng ta cũng định nghĩa kiểu cho dữ liệu gửi đi (LoginCredentials, RegisterData).
2. Typing Các Hàm Xác Thực (Authentication Functions)

Định nghĩa rõ ràng tham số đầu vào và kiểu trả về của các hàm như login, logout, register, checkAuthStatus, v.v.

// Trong file xử lý logic auth (ví dụ: src/services/authService.ts)
import { User, LoginCredentials } from '../types/auth';

// Giả định hàm này gọi API backend
export async function login(credentials: LoginCredentials): Promise<User> {
  console.log('Đang đăng nhập với:', credentials.username);
  // TODO: Thực hiện gọi API thật sự
  // Ví dụ giả lập:
  await new Promise(resolve => setTimeout(resolve, 1000)); // Giả lập độ trễ mạng

  if (credentials.username === 'test' && credentials.password === 'password') {
    const fakeUser: User = {
      id: 'user-123',
      username: 'test',
      email: 'test@example.com',
    };
    // TODO: Lưu token vào localStorage hoặc cookie
    console.log('Đăng nhập thành công, trả về user:', fakeUser);
    return fakeUser;
  } else {
    console.error('Đăng nhập thất bại: Sai tên đăng nhập hoặc mật khẩu');
    throw new Error('Invalid username or password');
  }
}

export async function logout(): Promise<void> {
  console.log('Đang đăng xuất...');
  // TODO: Gọi API logout (nếu có) và xóa token
  await new Promise(resolve => setTimeout(resolve, 500)); // Giả lập độ trễ

  // TODO: Xóa token khỏi localStorage/cookie
  console.log('Đăng xuất thành công');
}

export async function checkAuthStatus(): Promise<User | null> {
  console.log('Đang kiểm tra trạng thái xác thực...');
  // TODO: Lấy token từ localStorage/cookie và gọi API kiểm tra token
  await new Promise(resolve => setTimeout(resolve, 700)); // Giả lập độ trễ

  // Ví dụ giả lập: kiểm tra localStorage
  const token = localStorage.getItem('authToken'); // Giả định bạn lưu token ở đây
  if (token) {
    // Giả định API trả về thông tin user nếu token hợp lệ
    // const user = await api.getUserFromToken(token);
    const fakeUser: User = { // Giả lập user từ token
       id: 'user-xyz',
       username: 'authenticated_user',
       email: 'auth@example.com'
    };
    console.log('Đã xác thực, user:', fakeUser);
    return fakeUser;
  }
  console.log('Chưa xác thực');
  return null;
}
  • Giải thích:
    • Hàm login nhận credentials có kiểu LoginCredentials và trả về một Promise sẽ giải quyết thành kiểu User.
    • Hàm logout không nhận tham số và trả về Promise<void> vì nó không cần trả về dữ liệu cụ thể.
    • Hàm checkAuthStatus không nhận tham số và trả về Promise<User | null>, cho biết nó có thể trả về một object User nếu đã xác thực hoặc null nếu chưa.
    • Việc sử dụng Promise<> là quan trọng vì các hàm này thường bất đồng bộ. TypeScript giúp bạn đảm bảo bạn xử lý kết quả một cách đúng đắn (ví dụ: dùng await).
3. Typing Trạng Thái Xác Thực (Authentication State)

Trong ứng dụng front-end, trạng thái xác thực thường được quản lý trong state của component, context API, hoặc global state management (Redux, Zustand, v.v.). Việc định kiểu cho trạng thái này là cực kỳ quan trọng.

Một trạng thái xác thực điển hình có thể bao gồm:

  • user: Thông tin người dùng (User | null). null nếu chưa đăng nhập.
  • isLoading: Boolean, cho biết đang trong quá trình kiểm tra trạng thái ban đầu hoặc đang xử lý login/logout.
  • error: String hoặc đối tượng Error | null, chứa thông báo lỗi nếu có vấn đề xảy ra.
// Trong file quản lý state/context (ví dụ: src/context/AuthContext.tsx)
import { User } from '../types/auth';

// Định nghĩa kiểu cho trạng thái xác thực
export interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null; // Hoặc Error | null
}

// Định nghĩa kiểu cho giá trị được cung cấp bởi Context (bao gồm state và các hàm)
export interface AuthContextType extends AuthState {
  login: (username: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  // Thêm các hàm khác nếu có (register, forgot password, ...)
}
  • Giải thích:
    • AuthState mô tả cấu trúc của dữ liệu trạng thái. Lưu ý user có thể là User hoặc null.
    • AuthContextType mở rộng từ AuthState và thêm các hàm hành động (login, logout) mà context cung cấp. Việc định kiểu cho các hàm này giúp đảm bảo khi sử dụng context, bạn gọi hàm với đúng tham số và xử lý kết quả mong đợi.
4. Sử Dụng Typing Trong Logic (Ví Dụ React Context)

Kết hợp các kiểu đã định nghĩa vào triển khai thực tế (ví dụ: sử dụng React Context để quản lý trạng thái xác thực).

// src/context/AuthContext.tsx (Tiếp theo từ bước 3)
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { login as authLogin, logout as authLogout, checkAuthStatus } from '../services/authService'; // Nhập các hàm từ service
import { AuthState, AuthContextType } from '../types/auth'; // Nhập các kiểu

// Khởi tạo Context với kiểu đã định nghĩa
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Provider Component
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [state, setState] = useState<AuthState>({ // Sử dụng AuthState cho state
    user: null,
    isLoading: true, // Ban đầu là true để kiểm tra trạng thái
    error: null,
  });

  // Effect kiểm tra trạng thái xác thực ban đầu khi ứng dụng load
  useEffect(() => {
    const checkStatus = async () => {
      try {
        const user = await checkAuthStatus(); // Hàm đã được typed trả về User | null
        setState(prevState => ({ ...prevState, user, isLoading: false, error: null }));
      } catch (err: any) { // Bắt lỗi và định kiểu cho err
        setState(prevState => ({ ...prevState, user: null, isLoading: false, error: err.message }));
      }
    };
    checkStatus();
  }, []);

  // Hàm login sử dụng kiểu từ AuthContextType
  const login = async (username, password) => { // TS biết username, password là string nhờ AuthContextType
    setState(prevState => ({ ...prevState, isLoading: true, error: null }));
    try {
      const user = await authLogin({ username, password }); // authLogin trả về Promise<User>
      setState(prevState => ({ ...prevState, user, isLoading: false }));
      // TODO: Có thể redirect hoặc thông báo thành công
    } catch (err: any) {
      setState(prevState => ({ ...prevState, user: null, isLoading: false, error: err.message }));
      throw err; // Ném lỗi để component gọi handle
    }
  };

  // Hàm logout sử dụng kiểu từ AuthContextType
  const logout = async () => { // TS biết hàm này không nhận tham số và trả về Promise<void>
    setState(prevState => ({ ...prevState, isLoading: true, error: null }));
    try {
      await authLogout(); // authLogout trả về Promise<void>
      setState(prevState => ({ ...prevState, user: null, isLoading: false }));
      // TODO: Có thể redirect về trang login
    } catch (err: any) {
       setState(prevState => ({ ...prevState, isLoading: false, error: err.message }));
       throw err;
    }
  };

  // Giá trị Context cung cấp, tuân thủ AuthContextType
  const contextValue: AuthContextType = {
    ...state, // user, isLoading, error từ state
    login,    // hàm login đã định kiểu
    logout,   // hàm logout đã định kiểu
  };

  return (
    <AuthContext.Provider value={contextValue}>
      {children}
    </AuthContext.Provider>
  );
};

// Custom hook để sử dụng context dễ dàng
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context; // Trả về giá trị có kiểu AuthContextType
};
  • Giải thích:
    • useState<AuthState> đảm bảo state luôn có cấu trúc đúng như AuthState.
    • createContext<AuthContextType | undefined>(undefined) tạo context với kiểu giá trị là AuthContextType hoặc undefined (trước khi provider được render).
    • contextValue: AuthContextType = { ... } ép buộc giá trị được cung cấp phải khớp với kiểu AuthContextType, bao gồm cả user, isLoading, error, và các hàm login, logout.
    • Hàm loginlogout gọi các hàm đã được typed từ authService. TypeScript sẽ kiểm tra xem bạn có gọi đúng cách không (ví dụ: truyền đúng tham số cho authLogin).
    • Hook useAuth trả về context có kiểu AuthContextType, giúp bạn an toàn khi truy cập user, isLoading, login, logout trong các component sử dụng hook này. Nếu bạn cố gắng truy cập context.user.ageage không có trong User interface, TypeScript sẽ báo lỗi ngay lập tức.
5. Sử Dụng Hook useAuth Trong Components

Khi sử dụng hook useAuth trong các component, bạn sẽ thấy rõ lợi ích của việc typing.

// src/components/UserProfile.tsx
import React from 'react';
import { useAuth } from '../context/AuthContext'; // Nhập hook

function UserProfile() {
  const { user, isLoading, logout, error } = useAuth(); // Các thuộc tính này đã được typed nhờ useAuth hook

  if (isLoading) {
    return <div>Đang tải thông tin người dùng...</div>;
  }

  if (error) {
    return <div>Lỗi: {error}</div>;
  }

  if (!user) {
    // Nếu chưa đăng nhập, có thể hiển thị nút login hoặc redirect
    return <div>Bạn chưa đăng nhập.</div>;
  }

  // Khi user đã tồn tại và có kiểu User, bạn có thể truy cập an toàn các thuộc tính của nó
  return (
    <div>
      <h2>Xin chào, {user.username}!</h2> {/* TS biết user  thuộc tính username */}
      <p>Email: {user.email}</p> {/* TS biết user  thuộc tính email */}
      {/* Nếu bạn có user.avatarUrl (optional) */}
      {user.avatarUrl && <img src={user.avatarUrl} alt="Avatar" />}
      <button onClick={() => logout()}>Đăng xuất</button> {/* TS biết logout  hàm async  không cần tham số */}
    </div>
  );
}

export default UserProfile;
  • Giải thích:
    • Khi gọi useAuth(), TypeScript biết rằng object trả về có kiểu AuthContextType và chứa các thuộc tính user, isLoading, error, login, logout với các kiểu đã định nghĩa.
    • Khi kiểm tra if (!user), trong block if tiếp theo, TypeScript biết rằng user chắc chắn là kiểu User (không phải null) và cho phép bạn truy cập các thuộc tính của nó (user.username, user.email) một cách an toàn mà không cần kiểm tra thêm (như user && user.username).
    • Bạn có thể tự tin gọi logout()TypeScript đảm bảo nó là một hàm tồn tại và không yêu cầu tham số.

Lợi Ích To Lớn

Việc dành thời gian typing cho logic xác thực tùy chỉnh mang lại những lợi ích không nhỏ:

  • Giảm thiểu lỗi runtime: Hầu hết các lỗi liên quan đến truy cập thuộc tính không tồn tại hoặc sai kiểu dữ liệu sẽ bị bắt bởi TypeScript khi biên dịch.
  • Cải thiện khả năng đọc code: Khi nhìn vào một hàm hoặc một object, bạn biết ngay nó mong đợi gì và sẽ trả về gì nhờ các định nghĩa kiểu rõ ràng.
  • Tăng tốc độ phát triển: Với Intellisense/Autocompletion được hỗ trợ bởi TypeScript, bạn có thể thấy ngay các thuộc tính và phương thức có sẵn trên object, giúp viết code nhanh hơn và chính xác hơn.
  • Dễ dàng bảo trì và refactor: Khi cần thay đổi cấu trúc dữ liệu người dùng hoặc chữ ký hàm, TypeScript sẽ chỉ ra tất cả những nơi trong code bị ảnh hưởng, giúp bạn cập nhật code một cách an toàn.
  • Tăng cường sự tự tin: Bạn có thể code với sự tự tin cao hơn khi biết rằng trình biên dịch đã kiểm tra tính đúng đắn về kiểu dữ liệu cho bạn.

Những Điểm Cần Lưu Ý Thêm

  • Typing phản hồi API: Hãy cố gắng định nghĩa kiểu cho phản hồi từ các API liên quan đến xác thực. Điều này giúp bạn chắc chắn về dữ liệu bạn nhận được và gán vào state hoặc object User. Các thư viện như zod hoặc io-ts có thể giúp bạn xác thực dữ liệu nhận được từ API tại runtime (runtime validation) kết hợp với typing (static typing).
  • Typing lưu trữ cục bộ (localStorage, cookies): Nếu bạn lưu trữ token hoặc thông tin người dùng ở localStorage/cookies, hãy cẩn thận với kiểu dữ liệu. Dữ liệu từ storage luôn là string, bạn cần parse (JSON.parse) và có thể cần thêm bước xác thực để đảm bảo cấu trúc đúng trước khi sử dụng.
  • Error Handling: Định kiểu cho cả các đối tượng lỗi (Error | null hoặc một interface lỗi tùy chỉnh) giúp bạn xử lý lỗi một cách nhất quán và an toàn.

Comments

There are no comments at the moment.