Input Validation
Validation ใน GraphQL เกิดขึ้นในสองชั้นที่แตกต่างกันชัดเจน และการสับสนระหว่างสองชั้นนี้คือต้นเหตุของ API ที่ไม่ดีอยู่บ่อยครั้ง ชั้นแรกคือชั้นโครงสร้าง schema เองบังคับใช้ type, argument ที่จำเป็น และการเป็นสมาชิกของ enum ก่อนที่ resolver จะรันเสียอีก ชั้นที่สองคือชั้นความหมาย ซึ่งคือ business rules เช่น “email ต้องไม่ซ้ำ” หรือ “rating อยู่ระหว่าง 1 ถึง 5” ที่มีแต่codeของคุณเท่านั้นที่รู้ schema จัดการชั้นแรกให้ฟรีๆ ส่วนชั้นที่สองเป็นงานของคุณ
สิ่งที่ schema validate ให้ฟรีๆ
หัวข้อที่มีชื่อว่า “สิ่งที่ schema validate ให้ฟรีๆ”ประกาศ input ไว้ แล้ว engine จะเฝ้าให้เอง ถ้า argument เป็น non-null การไม่ส่งมาคือ request error ถ้าประกาศเป็น Int แล้วส่ง string ไปก็คือ request error ถ้าเป็น enum แล้วส่งค่านอก enum ก็โดนปฏิเสธ ทั้งหมดนี้ถูกดักใน validation phase ก่อน execute ความล้มเหลวเหล่านี้จึงกลับมาเป็น request error ที่ไม่มี data ตรงกับที่เห็นเมื่อสองบทก่อน
input ReviewInput { trackId: ID! rating: Int! comment: String}
type Mutation { addReview(input: ReviewInput!): Review}ด้วย schema นี้ คุณจะเรียก addReview โดยไม่มี input ไม่ได้ ละ trackId หรือ rating ไม่ได้ และส่ง rating ที่ไม่ใช่จำนวนเต็มก็ไม่ได้ type system ปฏิเสธทั้งหมดนี้โดยไม่ต้องเขียน code ใน resolver สักบรรทัด แต่สิ่งที่ type system ไม่มีทางรู้คือ rating ต้องอยู่ระหว่าง 1 ถึง 5 หรือ comment ต้องสั้นกว่า 500 ตัวอักษร เพราะนั่นคือ business rule
สองวิธีในการรายงานความล้มเหลวของ business-rule
หัวข้อที่มีชื่อว่า “สองวิธีในการรายงานความล้มเหลวของ business-rule”เมื่อ business rule ล้มเหลวใน resolver ของคุณ คุณมีทางเลือกในการออกแบบ และทั้งสองแนวทางก็ถูกต้องชอบธรรม
flowchart TD Input["Incoming arguments"] Schema["Schema validation: types, required, enums"] ReqErr["Fails structure: request error, no data"] Resolver["Resolver: business rules"] Throw["Unrecoverable: throw GraphQLError -> top-level errors"] Payload["Recoverable: return userErrors in payload -> normal data"] Input --> Schema Schema -->|"invalid shape"| ReqErr Schema -->|"valid shape"| Resolver Resolver -->|"system fault"| Throw Resolver -->|"input mistake"| Payload
Throw โยน GraphQLError พร้อม code อย่าง BAD_USER_INPUT ความล้มเหลวจะตกไปอยู่ใน errors array ระดับ top-level และ data ของ field นั้นเป็น null วิธีนี้ง่ายและเป็นแบบแผนเดียวกัน แต่ปนความผิดพลาดของ input ที่คาดไว้และกู้คืนได้เข้ากับ system error ตัวจริง แล้ว client ก็ต้องไปขุดใน errors เพื่อหาให้เจอ
Return userErrors ใน payload ออกแบบ return type ของ mutation ให้เป็น payload ที่บรรจุ ทั้ง ผลลัพธ์และรายการ typed user errors ความล้มเหลวของ validation จึงกลายเป็น data ธรรมดา ไม่ใช่ exception ดังนั้น client จึงอ่าน data.addReview.userErrors เหมือน field อื่นๆ นี่คือรูปแบบที่ schema ขนาดใหญ่จำนวนมากนำมาใช้ เพราะ error แบบฟอร์มที่กู้คืนได้กลายเป็นพลเมืองชั้นหนึ่ง มี type ที่แข็งแรง และไม่เคยไปพันกับช่องทาง errors
type UserError { field: String message: String!}
type AddReviewPayload { review: Review userErrors: [UserError!]!}หลักการง่ายๆ คือ throw สำหรับความล้มเหลวที่ผู้ใช้แก้ไม่ได้ (request ที่ผิดรูป หรือความผิดพลาดภายใน) และ return userErrors สำหรับความผิดพลาดที่กู้คืนได้ซึ่งคุณคาดว่าฟอร์มจะนำกลับไปแสดงให้คนเห็น
validate input ที่รันจริง
หัวข้อที่มีชื่อว่า “validate input ที่รันจริง”runner ด้านล่างใช้รูปแบบ payload resolver validate rating กับ business rule 1–5 แล้ว return review ที่ถูกสร้างขึ้น หรือรายการ userErrors ที่มีข้อมูล โดยทั้งหมดเป็น data ปกติ ไม่ใช่ exception เลย กด Run ด้วย rating ที่อยู่นอกช่วง แล้วดู userError กลับมาภายใน payload
สังเกตสิ่งที่ หายไป จาก output ไม่มี errors array ระดับ top-level เลย rating ที่เป็น 9 ผิด business rule ก็จริง แต่เพราะเราออกแบบให้ไปโผล่ที่ field userErrors ความล้มเหลวจึงมาถึงในรูป data ธรรมดา data.addReview.review เป็น null ส่วน data.addReview.userErrors เก็บ entry ที่มี type ซึ่งชี้ไปที่ field rating client จึงจัดการได้เหมือน selection ทั่วไป ลองเปลี่ยน rating เป็น 4 แล้วรันใหม่ review จะมีค่าขึ้นมาและ userErrors จะกลับมาว่าง
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| validate ก่อน resolver — ป้องกัน invalid data เข้า business logic | validation logic ซ้ำระหว่าง schema type system และ custom validator |
| error message เฉพาะ field — client รู้ว่าต้องแก้ field ไหน | zod/yup validation ใน resolver เพิ่ม dependency |
| type validation ฟรีจาก GraphQL type system | validation rule ที่ซับซ้อน (cross-field) ทำใน type system ยาก |
| Input Type validation เกิดก่อน resolver execute | business rule validation ยังต้องทำใน service layer |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Validation ใน Resolver ที่ Throw GraphQL Error
อาการ:
- resolver ทำ validation แล้ว throw error — client เห็น error ใน top-level
errorsarray - validation error ควรอยู่ใน payload ไม่ใช่ top-level error
- แยก system error (throw) กับ business validation error (payload.errors)
Validate เฉพาะ Type ไม่ Validate Business Rule
อาการ:
- email field เป็น
String!— validate แค่ว่าไม่ null - ไม่ validate ว่า email format ถูกต้องหรือ email ซ้ำในระบบ
- type system validate format, service layer validate business rule
💡 ตัวอย่างจากของจริง
Shopify:
- mutation return
userErrors: [UserError!]!สำหรับ business validationUserErrorมีfield(array ของ field path) และmessage- client รู้ว่า field ไหนผิดและแสดง error ที่ field นั้นได้
Zod + GraphQL:
- ใช้ Zod validate input ใน resolver แล้วแปลง ZodError เป็น GraphQL payload error
- type-safe validation ที่ share schema ระหว่าง client และ server ได้