Với 1 frontend developer, việc xử lý API là 1 tác vụ quan trọng trong xử lý thông tin giữa logic component và backend.2 nhiệm vụ chính của việc xử lý API đó là handle error,success và middleware (optional).
Còn với typescript,tất nhiên rồi,cùng với các utility tiện lợi và sự chắc chắn trong việc define đối tượng thì typescript luôn là lựa chọn hàng đầu để tích hợp vào các ngôn ngữ hiện nay.
Trong bài viết này,mình sẽ kết hợp giữa TS và xử lý error,success thông thường để giúp mọi người có thể xử lý một cách thuận tiện, nhanh chóng và linh hoạt trong việc handling API.
Vấn đề gặp phải
- Đôi khi, các kiểu dữ liệu phản hồi trong dự án được mô tả chỉ bằng một loại với nhiều parameter tùy chọn. Trong hầu hết các trường hợp thì có vẻ là đủ dùng nhưng sẽ cần kiểm tra thêm về tồn tại của các tham số này. Đây là một ví dụ như vậy:
export enum ApiStatus { OK = `ok`, ERROR = `error`, FORM_ERRORS = `form_errors`, REDIRECT = `redirect`,
} export type ApiData = { status: ApiStatus error?: string errors?: Record<string, string> url?: string
}
Ưu điểm duy nhất của phương pháp này là sự đơn giản của nó. Chúng ta có thể thêm loại ApiData vào bất kỳ loại phản hồi nào và thế là đủ.
export type UserProfile = { id: number name: string last_name: string birthday: string city: string
} export type UserProfileResponse = ApiData & { user: UserProfile
} // to simulate an API call
const updateProfileAPI = async(data: Partial<UserProfile>): Promise<UserProfileResponse> => { return Promise.resolve({} as UserProfileResponse)
}
Tuy nhiên, mình tin rằng lợi thế duy nhất này sẽ tồn tại một bất lợi khá lớn. Nhược điểm của phương pháp này là thiếu minh bạch.
Ngoài ra, bằng cách thêm type như trên vào loại response, bạn không bao giờ biết chính xác phản hồi cho một yêu cầu cụ thể sẽ là gì. Hãy tưởng tượng rằng đối với một request POST, bạn có thể có một số kịch bản phản hồi hạn chế từ API.
Các kịch bản đó có thể như sau:
- kết quả trả về thành công với status: 'ok' and data.
- lỗi trả về với status: 'form-errors' and errors: [{}, {}]
Điều đó có nghĩa là chúng ta sẽ không bao giờ có trạng thái: 'redirect' làm kịch bản phản hồi có thể xảy ra trong trường hợp này (và nếu thế chúng ta sẽ phải handle 1 cách cục bộ trong các component). Ngoài ra, tại sao ta lại cần return errors trong request GET? (đơn giản là GET chỉ cần quan tâm mã lỗi rồi define thông báo cụ thể không cần xử lý gì liên quan đến error logic)
Hóa ra là chúng ta không thể hiểu chính xác những res options mà chúng ta có chỉ bằng cách nhìn vào type của phản hồi. Để hiểu tất cả các kiểu phản hồi có thể xảy ra, bạn cần xem code của phần xử lý API trên cả frontend và backend.
Sử dụng các tiện ích trong TS vào response types
Những nhược điểm được mô tả ở trên có thể được giải quyết với sự trợ giúp của các utilities có sẵn. Chúng ta sẽ tách riêng chúng cho từng trường hợp: return success , server error, validation error hoặc redirect bắt buộc(ví dụ 401)
export enum ApiStatus { OK = `ok`, ERROR = `error`, FORM_ERRORS = `form_errors`, REDIRECT = `redirect`,
} export type ApiSuccess<T extends Record<string, unknown> | unknown = unknown> = T & { status: ApiStatus.OK,
} export type ApiError<T extends Record<string, unknown> = { error: string } > = T & { status: ApiStatus.ERROR,
} export type ApiFormErrors<T extends Record<string, unknown> = { errors: Record<string, string> }> = T & { status: ApiStatus.FORM_ERRORS,
} export type ApiRedirect<T extends Record<string, unknown> = { url: string }> = T & { status: ApiStatus.REDIRECT,
} export type ApiResponse<T extends Record<string, unknown> | unknown = unknown, K extends Record<string, unknown> = { error: string }, R extends Record<string, unknown> = { errors: Record<string, string> }> = ApiSuccess<T> | ApiError<K> | ApiFormErrors<R>
Ngoài ra, mình đã tạo loại ApiResponse chung cùng 1 số utilities trong TS(các bạn có thể tìm hiểu thêm về Record, Generic types và cách sử dụng extends trong TS). Nó sẽ tiết kiệm thời gian để thêm tất cả các kịch bản cho mỗi yêu cầu POST.
Dưới đây là ví dụ về việc sử dụng utilities cho các tình huống khác nhau:
export type FetchUserProfile = ApiSuccess<{ user: UserProfile
}> export type FetchUserConfig = ApiSuccess<{ config: Record<string, string | number | boolean>
}> | ApiError export type AddUserSocialNetworkAsLoginMethod = ApiResponse<{ social_network: string, is_active: boolean
}, { message: string }> | ApiRedirect<{ redirect_url: string }>
Áp dụng thực tế
Dưới đây là ví dụ về việc call api lấy profile user và response từ hàm cập nhật user.
const updateProfile = async(): Promise<void> => { try { const data = await updateProfileAPI({ name: 'New name' }) // [!!!] Typescript does not highlight that the 'user' property could not exist on the 'data' property // In the case when data.status === ApiStatus.ERROR|FORM_ERRORS|REDIRECT console.log(data.user.id) if (data.status === ApiStatus.OK) { updatedProfileState(data.user) return } if (data.status === ApiStatus.ERROR) { // Argument of type 'string | undefined' is not assignable to parameter of type 'string'. // Type 'undefined' is not assignable to type 'string'. showNotification('danger', data.error) return } if (data.status === ApiStatus.FORM_ERRORS) { // Argument of type 'Record<string, string> | undefined' is not assignable to parameter of type 'Record<string, string>'. // Type 'undefined' is not assignable to type 'Record<string, string>'. showValidationErrors(data.errors) return } if (data.status === ApiStatus.REDIRECT) { // Argument of type 'string | undefined' is not assignable to parameter of type 'string'. // Type 'undefined' is not assignable to type 'string'. redirect(data.url) return } throw new Error('Something went wrong...') } catch (err) { console.error('User: updateProfile - ', err) }
}
Và đây là code TS được detect bởi TSLint hoặc ESLint
Trong hình ảnh, ta có thể thấy một số giá trị như định nghĩa, chẳng hạn như lỗi, lỗi hoặc url, được TypeScript đánh dấu lỗi. Điều này là do Linter(TSLint hoặc ESLint) cho rằng những giá trị này có thể không được xác định. Điều này có thể dễ dàng giải quyết bằng cách kiểm tra bổ sung cùng với trạng thái, nhưng nó đã cho thấy vấn đề với phương pháp này.
Ngoài ra, chúng ta có dòng console.log(data.user.id), giá trị user không bị highlight lỗi undefined. Đây là cách sẽ diễn ra nếu ta nhận được bất kỳ loại phản hồi nào ngoài loại phản hồi thành công.
Sử dụng các loại tiện ích như ApiResponse và các loại tiện ích khác, chúng ta sẽ không gặp phải những vấn đề như vậy.
export type UserProfileResponseV2 = ApiResponse<{ user: UserProfile
}> | ApiRedirect const newUpdateProfileAPI = async(data: Partial<UserProfile>): Promise<UserProfileResponseV2> => { return Promise.resolve({} as UserProfileResponseV2)
}
Và đây là sau khi đc Lint:
Trong trường hợp này, mọi thứ hoạt động như mong đợi:
- TypeScript hiểu rằng đối với các trạng thái tương ứng sẽ có các trường tiêu chuẩn tương ứng.
- Nó chỉ ra rằng giá trị user có thể undifined trong tất cả các loại phản hồi ngoại trừ loại thành công. Tuy nhiên, sau khi kiểm tra thành công của phản hồi, giá trị này không được đánh dấu và được xác định.
Kết luận
Mình nghĩ sau khi sử dụng phương pháp trên với các utilities của TS, trải nghiệm của dev đã được cải thiện đáng kể. Giờ đây, các trường hợp trên hoàn toàn tương ứng với các tình huống phản hồi có thể có mà API có thể cung cấp.
Điều này cũng sẽ giúp tránh các lỗi tiềm ẩn trong đó một số giá trị có thể được sử dụng nhưng không tồn tại trong một số loại phản hồi nhất định, như trong ví dụ về giá trị user bên trên.
Ngoài ra, không cần phải xem cách triển khai xử lý phản hồi trong code để hiểu các loại phản hồi thực sự.