ข้ามไปยังเนื้อหา

Input Validation

Validation ใน GraphQL เกิดขึ้นในสองชั้นที่แตกต่างกันชัดเจน และการสับสนระหว่างสองชั้นนี้คือต้นเหตุของ API ที่ไม่ดีอยู่บ่อยครั้ง ชั้นแรกคือชั้นโครงสร้าง schema เองบังคับใช้ type, argument ที่จำเป็น และการเป็นสมาชิกของ enum ก่อนที่ resolver จะรันเสียอีก ชั้นที่สองคือชั้นความหมาย ซึ่งคือ business rules เช่น “email ต้องไม่ซ้ำ” หรือ “rating อยู่ระหว่าง 1 ถึง 5” ที่มีแต่codeของคุณเท่านั้นที่รู้ schema จัดการชั้นแรกให้ฟรีๆ ส่วนชั้นที่สองเป็นงานของคุณ

ประกาศ 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 ล้มเหลวใน 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
ความล้มเหลวของ business: throw error ระดับ top-level หรือ return userErrors ใน 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 สำหรับความผิดพลาดที่กู้คืนได้ซึ่งคุณคาดว่าฟอร์มจะนำกลับไปแสดงให้คนเห็น

runner ด้านล่างใช้รูปแบบ payload resolver validate rating กับ business rule 1–5 แล้ว return review ที่ถูกสร้างขึ้น หรือรายการ userErrors ที่มีข้อมูล โดยทั้งหมดเป็น data ปกติ ไม่ใช่ exception เลย กด Run ด้วย rating ที่อยู่นอกช่วง แล้วดู userError กลับมาภายใน payload

JavaScript

สังเกตสิ่งที่ หายไป จาก 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 logicvalidation logic ซ้ำระหว่าง schema type system และ custom validator
error message เฉพาะ field — client รู้ว่าต้องแก้ field ไหนzod/yup validation ใน resolver เพิ่ม dependency
type validation ฟรีจาก GraphQL type systemvalidation rule ที่ซับซ้อน (cross-field) ทำใน type system ยาก
Input Type validation เกิดก่อน resolver executebusiness rule validation ยังต้องทำใน service layer

Validation ใน Resolver ที่ Throw GraphQL Error

อาการ:

  • resolver ทำ validation แล้ว throw error — client เห็น error ใน top-level errors array
  • 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 validation
  • UserError มี 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 ได้
GraphQL schema บังคับใช้ validation ชนิดใดโดยอัตโนมัติ ก่อนที่ resolvers จะรัน?
business rules อย่าง "rating ต้องอยู่ระหว่าง 1–5" ต้องถูกบังคับใช้ที่ไหน?
ข้อได้เปรียบของการ return userErrors ภายใน mutation payload คืออะไร?
เมื่อใดที่การ throw GraphQLError ดีกว่าการ return userErrors?