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

Inputs และ Nullability

ส่วนประกอบพื้นฐานชิ้นสุดท้ายคือข้อมูลที่ไหล เข้า สู่ schema ของคุณ object type อธิบายสิ่งที่คืนกลับมา input object type อธิบายสิ่งที่ client ส่งเข้ามา เมื่อรวมกับ nullability, list และ ค่า default แล้ว input คือวิธีที่คุณออกแบบ argument ที่ทั้งสื่อความหมายได้ดีและทำให้ผิดพลาดได้ยาก บทเรียนนี้ปิดท้ายโมดูลด้วยฝั่งของสัญญาที่ client เป็นผู้เขียนเข้ามา

เมื่อ field รับ argument มากกว่าสองสามตัว การไล่ลิสต์ทั้งหมดแบบ inline จะเริ่มเทอะทะ input object type จะมัด argument ที่เกี่ยวข้องกันไว้ในโครงสร้างเดียวที่มีชื่อ หน้าตาคล้าย object type แต่ประกาศด้วย keyword input และมีได้แค่ scalar, enum, list และ input ตัวอื่นเท่านั้น — เป็น object type ไม่ได้:

input CreateBookInput {
title: String!
authorId: ID!
pageCount: Int
}
type Mutation {
createBook(input: CreateBookInput!): Book
}

ตอนนี้ createBook รับ argument ตัวเดียวคือ input แทนที่จะเป็นสามตัวกระจัดกระจาย นี่คือรูปแบบมาตรฐานของ mutation เพราะ argument input ตัวเดียวทำให้ signature ของ mutation นิ่ง แม้ field ข้างในจะเปลี่ยนไปเรื่อย ๆ GraphQL แยก input กับ object type ออกจากกันโดยตั้งใจ — object type ใช้กับ output ส่วน input type ใช้กับ input และจะปนกันไม่ได้

การอ้างถึง type ทุกตัว เป็น nullable โดยปริยาย ใส่ ! ต่อท้ายเมื่อไหร่ก็กลายเป็น non-null ทันที บน field ของ input เครื่องหมาย ! แปลว่า client ต้อง ส่งค่ามา:

input CreateBookInput {
title: String! # required — the client must send it
authorId: ID! # required
pageCount: Int # optional — may be omitted or sent as null
}

title กับ authorId เป็น non-null ถ้าไม่ส่งมาจึงถือเป็น validation error ที่ถูกดักตั้งแต่ก่อน resolver ตัวไหนจะทำงาน ส่วน pageCount เป็น nullable จึงเป็น optional nullability บน input คือวิธีบอกว่า “จำเป็น vs ไม่จำเป็น” โดยไม่ต้องเขียน code ตรวจสอบสักบรรทัด เพราะ type system บังคับให้เอง

list type เขียนด้วยวงเล็บเหลี่ยม เช่น [Book] เพราะทั้งตัว list เองและสมาชิกข้างในเป็น null ได้อิสระจากกัน list type จึงมักมี ! สองตัวที่ทำหน้าที่ต่างกัน:

input AddTagsInput {
bookId: ID!
tags: [String!]!
}

ถอดรหัส [String!]! จากในออกนอก:

  • String! ด้านในหมายความว่า ทุกสมาชิก ของ list เป็น string ที่ non-null — ไม่มีรู null อยู่ข้างใน
  • ! ด้านนอกแปลว่า ตัว list เอง ไม่มีทางเป็น null — แต่ยังว่างเปล่า [] ได้

ดังนั้น tags เป็น list เสมอ และทุก tag ในนั้นเป็น string จริง ๆ การอ่านแบบเดียวกันใช้กับ output field อย่าง [Book!]! การเลือกชุดเครื่องหมายที่ถูกต้องบอก client เป๊ะ ๆ ว่าจะคาดหวังอะไร: client ต้องจัดการกับ list ที่หายไปไหม? list ที่มีช่องว่างไหม? [String!]! ตอบว่าไม่ ทั้งสองข้อ

flowchart TD
  Mutation["Mutation.createBook(input)"]
  Input["input CreateBookInput"]
  Required["title: String! (required)"]
  Required2["authorId: ID! (required)"]
  Optional["pageCount: Int (optional)"]
  List["tags: [String!]! (non-null list of non-null)"]
  Mutation --> Input
  Input --> Required
  Input --> Required2
  Input --> Optional
  Input --> List
input object มัด argument เข้าด้วยกัน; nullability และ list กำหนดว่าอะไรจำเป็น

argument หรือ field ของ input ที่เป็น optional ประกาศ ค่า default ด้วย = ได้ ถ้า client ไม่ส่งมา GraphQL จะใช้ค่า default แทน:

type Query {
books(first: Int = 10, sortBy: String = "title"): [Book!]!
}

ถ้า client เรียก books โดยไม่ส่ง argument จะได้หนังสือ 10 เล่มแรกเรียงตาม title ค่า default ทำให้คง argument เป็น optional ไว้ได้ พร้อมส่งค่าที่สมเหตุสมผลให้ resolver ทำงานต่อ resolver จึงไม่เคยเห็น undefined ของ first แต่เห็น 10 เสมอ field ที่มี default ถือเป็น optional โดยนิยามอยู่แล้ว อย่าไปใส่ ! ให้ขัดกับ default

มีนิสัยไม่กี่อย่างที่ทำให้ input ใช้ง่ายในระยะยาว ห่อ argument ของ mutation ไว้ใน input type เดียวเพื่อให้ signature นิ่ง ใส่ non-null เฉพาะตอนที่ขาดค่านั้นแล้วทำงานต่อไม่ได้จริง ๆ เพราะใช้ ! เยอะเกินจะทำให้ API แข็งทื่อ และพัง client ทันทีที่เงื่อนไขผ่อนลง ใส่ค่า default ให้ pagination กับการเรียงลำดับ เพื่อให้เคสง่าย ๆ เรียกได้ง่ายจริง และคง input type ให้โฟกัส — หนึ่ง input ต่อหนึ่งงาน ตั้งชื่อตามงานนั้น ดีกว่ากองรวม field optional ไว้ในถุงใบใหญ่ใบเดียว

ตัวอย่างด้านล่างนิยาม CreateBookInput พร้อม mutation ที่รับ input นั้น แล้วรัน mutation ด้วย input บางส่วน โดยอาศัย nullability ให้ละ field ที่เป็น optional ได้ กด Run

JavaScript

mutation ส่งมาแค่สอง field ที่จำเป็น เพราะ pageCount เป็น nullable ใน input การไม่ส่งจึงถูกต้องตามกฎ แล้ว resolver ก็เติม null ให้เอง ส่วน field tags คืนกลับมาเป็น [] คือ list ที่ non-null แต่ว่างเปล่า ตรงตามที่ [String!]! ระบุไว้เป๊ะ type system ตรวจ input ทั้งหมดตั้งแต่ก่อน resolver จะทำงานด้วยซ้ำ

ข้อดีข้อแลกเปลี่ยน
Input Type รวม argument ไว้ที่เดียว — signature เสถียรเมื่อเพิ่ม fieldInput type แยกจาก output type — ต้องนิยาม 2 ชุดสำหรับ entity เดียว
nullability บน input เป็น self-documenting — required vs optional ชัดเจน! มากเกินไปทำ API แข็งทื่อ — เปลี่ยน required เป็น optional ทีหลังยาก
[T!]! บอก client ชัดว่า list รับประกัน non-null ทุก elementnullable field จำนวนมากใน input เดียวทำ mutation design สับสน
ค่า default ลด boilerplate ใน clientdefault ที่ซับซ้อนซ่อน behavior — ต้องอ่าน schema ดี

Input เดียวสำหรับ Create และ Update อาการ:

  • BookInput ที่ทั้ง createBook และ updateBook ใช้ร่วมกัน
  • create ต้องการ title! แต่ update ต้องการ title?
  • แยก CreateBookInput และ UpdateBookInput ออกจากกัน

ทำ Field ทุกตัวเป็น Non-null อาการ:

  • UpdateBookInput { title: String!, authorId: ID!, pageCount: Int! }
  • client ต้องส่งทุก field แม้ต้องการ update แค่ field เดียว
  • partial update ควรใช้ nullable field เพื่อ “ไม่ส่ง = ไม่เปลี่ยน”

💡 ตัวอย่างจากของจริง

Relay Convention:

  • mutation ทุกตัวรับ input: XxxInput! argument เดียว
  • pattern นี้ทำให้ schema evolve ได้โดยไม่เปลี่ยน mutation signature

GitHub:

  • ใช้ naming convention CreateIssueInput, UpdateIssueInput, CloseIssueInput
  • แต่ละ operation มี input type เฉพาะตัว — ไม่ใช้ร่วมกัน
input object type ใช้สำหรับอะไร?
บน field ของ input เครื่องหมาย ! ต่อท้ายหมายความว่าอะไร?
type [String!]! อธิบายอะไร?
ค่า default (เช่น first: Int = 10) ทำให้เกิดอะไร?