วันที่ที่ไม่มีเวลา
ร้านของคุณอยู่ใน Bangkok ใบเสร็จของออเดอร์ใบหนึ่งพิมพ์วันส่งของไว้ว่า 2026-10-02
ไม่มีเวลาและไม่มีโซน เพราะร้านสัญญาเป็นวัน ไม่ได้สัญญาเป็นชั่วโมง
ฟอร์มส่งค่านี้เป็นสตริงไปที่ service หลายตัว ตัวหนึ่งคัดออเดอร์ของแต่ละวันให้ทีมจัดรถ อีกตัวส่งข้อความเตือนลูกค้า ทุกตัวต้องแปลงสตริงนี้เป็นเวลาก่อนจะเปรียบเทียบอะไรได้
คำถามของบทนี้คือ วันส่งของ 2026-10-02 กลายเป็นวันอื่นได้อย่างไร คำตอบอยู่ที่ว่าใครเติมเวลา
และโซนที่สตริงไม่ได้ให้มา และในทรงที่ probe วัด ห้าภาษาจัดการช่องว่างนั้นต่างกัน
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ
ระหว่างนี้ทุกค่าในบทนี้รันเองได้ด้วย npm run verify:time-semantics
ซึ่งเรียก python3 scripts/time-semantics/probe.py · ไม่ต้องมี Docker แต่การรันครั้งแรก
ต้องต่อเน็ตเพื่อดึง chrono กับ chrono-tz จาก crates.io
ค่าที่มีแค่วันที่
หัวข้อที่มีชื่อว่า “ค่าที่มีแค่วันที่”วันส่งของบนใบเสร็จบอกวันบนปฏิทิน แต่ยังไม่ได้บอกว่าวันนั้นเริ่มที่จุดไหนบนเส้นเวลา
2026-10-02 บนใบเสร็จคือ date-only valuedate-only valueค่าที่มีแค่วันที่ ไม่มีเวลาและไม่มีโซน เช่นวันส่งของ · พอแปลงเป็น instant แต่ละภาษาเติมเวลากับโซนให้เองคนละแบบ วันที่เดียวกันจึงกลายเป็นคนละวันใน UTC ได้ (ค่าที่มีแค่วันที่) มันบอกว่าเป็นวันไหน
บนปฏิทิน แต่ไม่มีเวลาและไม่มีโซนติดมาด้วย
ระบบจัดรถต้องการ instant เพราะต้องเทียบว่าออเดอร์ไหนอยู่ในวันไหน วันเดียวกันบนปฏิทินเริ่มคนละ instant ในแต่ละโซน การแปลงวันที่เป็น instant จึงต้องมีโซนมาตอบว่าวันนั้นเริ่มเมื่อไร
สตริง 2026-10-02 เขียนตาม ISO 8601ISO 8601มาตรฐานสากลของการเขียนวันที่และเวลาเป็นสตริง เช่น 2026-10-02 หรือ 2026-10-02T09:00:00+07:00 · มันบอกได้แค่ offset หรือ UTC ไม่มีช่องสำหรับชื่อโซน (มาตรฐานการเขียนวันที่และเวลาเป็นสตริง)
สเปก ECMAScript เขียนว่ารูปแบบสตริงของ Date “is adapted from the ISO 8601 calendar date extended format”
สเปกเดียวกันแบ่งรูปแบบนั้นเป็นสองกลุ่ม คือ date-only forms กับ date-time forms และ date-time forms มีเวลาต่อท้าย “with an optional UTC offset representation appended” คือ offset เป็นของที่ไม่บังคับ
สองสตริงที่ต่างกันแค่ T00:00
หัวข้อที่มีชื่อว่า “สองสตริงที่ต่างกันแค่ T00:00”Node อ่านสตริงที่มีแค่วันที่เป็น UTC แต่อ่านสตริงที่มีเวลาต่อท้ายตาม
TZของ process
ทุกการเรียกในบทนี้ตัดมาจาก runner ของ probe ใน scripts/time-semantics/runners/ และค่าในคอมเมนต์
หรือในตารางคือค่าที่ probe วัดได้
new Date("2026-10-02").toISOString();new Date("2026-10-02T00:00").toISOString();probe รัน file เดียวกันสองครั้ง ต่างกันที่ TZ อย่างเดียว
TZ | new Date("2026-10-02") | new Date("2026-10-02T00:00") |
|---|---|---|
America/New_York | 2026-10-02T00:00:00.000Z | 2026-10-02T04:00:00.000Z |
Asia/Bangkok | 2026-10-02T00:00:00.000Z | 2026-10-01T17:00:00.000Z |
column ของสตริงที่มีแค่วันที่ได้ค่าเดียวกันทั้งสอง TZ คือเที่ยงคืนของ UTC ส่วน column ของสตริงที่มี T00:00
ได้คนละค่า เพราะ Node อ่านมันเป็นเที่ยงคืนในโซนของ process
กฎนี้ไม่ใช่ความบังเอิญของ Node สเปก ECMAScript หัวข้อ Date.parse เขียนไว้ว่า
“When the UTC offset representation is absent, date-only forms are interpreted as a UTC time
and date-time forms are interpreted as a local time.”
new Date ที่รับสตริงตัวเดียวก็ใช้กฎเดียวกัน เพราะสเปกของ Date ( ...values ) เขียนว่ามัน parse สตริง
“in exactly the same manner as for the parse method”
flowchart LR
A["วันส่งของ · 2026-10-02"] --> B{"สตริงมีเวลาต่อท้ายไหม"}
B -->|"ไม่มี · date-only form"| C["Node อ่านเป็น UTC"]
C --> D["2026-10-02T00:00:00.000Z · ทั้งสอง TZ ที่วัด"]
B -->|"มี · ต่อ T00:00"| E{"TZ ของ process"}
E -->|"America/New_York"| F["2026-10-02T04:00:00.000Z"]
E -->|"Asia/Bangkok"| G["2026-10-01T17:00:00.000Z"]
คำบรรยายภาพ: T00:00 ที่ต่อท้ายสตริงไม่ได้เพิ่มแค่เวลา มันเปลี่ยนโซนที่ Node ใช้อ่านทั้งค่า
จาก UTC ที่ตายตัว ไปเป็นโซนของเครื่องที่รัน
T00:00 ดูเหมือนวิธีพูดว่า “เริ่มวัน” ให้ชัดขึ้น แต่สิ่งที่มันทำจริงคือย้ายการตัดสินใจเรื่องโซน
จาก code ไปอยู่ที่การตั้งค่าของเครื่อง
อีกสี่ภาษากับวันที่ค่าเดียวกัน
หัวข้อที่มีชื่อว่า “อีกสี่ภาษากับวันที่ค่าเดียวกัน”Go เติม UTC ให้ Python ไม่เติมโซน ส่วน Java กับ Rust ได้ instant เมื่อผู้เรียกส่งโซนเข้าไปเอง
Go เติม UTC ให้
หัวข้อที่มีชื่อว่า “Go เติม UTC ให้”d, err := time.Parse("2006-01-02", "2026-10-02")// ...d.Format(time.RFC3339) // 2026-10-02T00:00:00Zd.Location().String() // UTClayout 2006-01-02 ไม่มีช่องของโซน Go จึงคืนเที่ยงคืนที่ location UTC เอกสารของ time.Parse
เขียนไว้ว่า “In the absence of a time zone indicator, Parse returns a time in UTC.”
ผลนี้ตรงกับ Node ที่อ่านสตริงที่มีแค่วันที่ทั้งสอง TZ ที่วัด ทั้งสองภาษาเลือก UTC ให้โดยไม่ถาม และไม่มีอะไรในค่าที่คืนมา
บอกว่า UTC เป็นค่าที่ภาษาเติมให้ ไม่ใช่ค่าที่สตริงให้มา
Python คืนค่าที่ไม่มีโซน
หัวข้อที่มีชื่อว่า “Python คืนค่าที่ไม่มีโซน”from datetime import datetime
d = datetime.fromisoformat("2026-10-02")str(d) # 2026-10-02 00:00:00repr(d.tzinfo) # NonePython เติมเวลาเป็นเที่ยงคืนให้ แต่ไม่เติมโซน ค่าที่ได้คือ naive datetimenaive datetimedatetime ที่ไม่มีโซนหรือ offset ติดมา · ใน Python คือค่าที่ tzinfo เป็น None ค่านี้บอกเวลาบนหน้าปัดได้ แต่บอกไม่ได้ว่าเป็นจุดไหนบนเส้นเวลา
(datetime ที่ไม่มีโซนหรือ offset ติดมา) คือ tzinfo เป็น None
เอกสารของ datetime เขียนว่า “A naive object does not contain enough information to unambiguously
locate itself relative to other date/time objects.” ค่านี้จึงยังไม่ใช่ instant มันรอให้ส่วนอื่นของโปรแกรม
ตัดสินว่ามันอยู่ในโซนไหน
ข้อดีคือ Python ไม่ได้เลือกโซนผิดให้ ราคาคือมันไม่ได้เลือกโซนให้เลย และการตัดสินนั้นจะไปเกิดที่ไหนสักแห่ง ใน code ที่อาจไม่มีใครตั้งใจเขียน
Java ได้ instant เมื่อได้โซน
หัวข้อที่มีชื่อว่า “Java ได้ instant เมื่อได้โซน”ZoneId bkk = ZoneId.of("Asia/Bangkok");LocalDate.parse("2026-10-02").atStartOfDay(bkk).toInstant().toString(); // 2026-10-01T17:00:00ZJavadoc นิยาม LocalDate ว่า “A date without a time-zone in the ISO-8601 calendar system”
ชนิดของ Java จึงแยกวันที่ออกจากเวลาตั้งแต่ชื่อ
ในการเรียกที่ probe ใช้ ทางจาก LocalDate ไปถึง Instant ผ่าน atStartOfDay(bkk) ซึ่งรับโซนเป็น argument
Javadoc เขียนว่า method นี้ “Returns a zoned date-time from this date at the earliest valid time
according to the rules in the time-zone.” คนอ่าน code จึงเห็นว่าวันนั้นเริ่มในโซนไหน
Rust ได้ instant เมื่อได้โซน แต่ parse_from_rfc3339 ไม่รับวันที่ล้วน
หัวข้อที่มีชื่อว่า “Rust ได้ instant เมื่อได้โซน แต่ parse_from_rfc3339 ไม่รับวันที่ล้วน”use chrono::{DateTime, NaiveDate, TimeZone, Utc};use chrono_tz::Asia::Bangkok;
let d: NaiveDate = "2026-10-02".parse().unwrap();let start = Bangkok.from_local_datetime(&d.and_hms_opt(0, 0, 0).unwrap()).single().unwrap();start.with_timezone(&Utc).to_rfc3339(); // 2026-10-01T17:00:00+00:00
match DateTime::parse_from_rfc3339("2026-10-02") { Ok(v) => format!("Ok({})", v.to_rfc3339()), Err(e) => format!("Err({})", e), // Err(premature end of input)};ทางแรกเริ่มจาก NaiveDate ซึ่งเป็นวันที่ล้วน แล้วต้องเติมเวลาเอง และต้องเลือกโซนเองด้วย
Bangkok.from_local_datetime ซึ่งคืน LocalResult แบบเดียวกับบทที่ 1
ทางที่สองขอ date-time จากสตริงที่มีแค่วันที่ ค่าที่ runner บันทึกคือ Err(premature end of input)
เอกสารของ chrono อธิบาย parse_from_rfc3339 ว่า “Parses an RFC 3339 date-and-time string”
สตริงที่ไม่มีเวลาจึงไม่ใช่สิ่งที่มันรับ
วันส่งของค่าเดียว บนเครื่องที่วัด
หัวข้อที่มีชื่อว่า “วันส่งของค่าเดียว บนเครื่องที่วัด”รวมผลของห้าภาษาไว้ในตารางเดียว พร้อมโซนที่ผู้เรียกระบุ และรุ่นของ tzdata ที่แต่ละภาษาอ่านจริง
| ภาษา | การเรียก | โซนที่ผู้เรียกระบุ | ค่าที่วัดได้ | tzdata ที่ใช้ |
|---|---|---|---|---|
Node v24.19.0 ที่ TZ=America/New_York | new Date("2026-10-02") | ไม่มี | 2026-10-02T00:00:00.000Z | 2026b · ICU ที่ฝังใน node |
Node v24.19.0 ที่ TZ=Asia/Bangkok | new Date("2026-10-02") | ไม่มี | 2026-10-02T00:00:00.000Z | 2026b · ICU ที่ฝังใน node |
Go go1.27.0 | time.Parse("2006-01-02", ...) | ไม่มี | 2026-10-02T00:00:00Z · location UTC | 2026c · /usr/share/zoneinfo ของระบบ |
Python 3.14.6 | datetime.fromisoformat(...) | ไม่มี | 2026-10-02 00:00:00 · tzinfo เป็น None | 2026c · /usr/share/zoneinfo ของระบบ |
Java openjdk 25.0.4.1 | LocalDate.parse(...).atStartOfDay(bkk) | Asia/Bangkok | 2026-10-01T17:00:00Z | 2026b · tzdb.dat ของ JDK |
Rust chrono-tz 0.10.4 | Bangkok.from_local_datetime(...) | Asia/Bangkok | 2026-10-01T17:00:00+00:00 | 2025b · ฝังใน chrono-tz |
ทุกแถวที่ผู้เรียกไม่ได้ระบุโซน ไม่ได้ instant ที่เป็นจุดเริ่มวันของร้านใน Bangkok แถวของ Node กับ Go ได้เที่ยงคืนของ UTC และแถวของ Python ไม่ได้ instant เลย
สองแถวที่ระบุ Asia/Bangkok ได้ instant เดียวกัน เขียนต่างกันแค่ท้ายสตริง คือ Z ของ Java กับ +00:00
ของ Rust ซึ่งเป็นจุดเดียวกับที่ Node ให้เมื่อรันที่ TZ=Asia/Bangkok แล้วอ่าน 2026-10-02T00:00
ห้าภาษาบนเครื่องเดียวกันอ่าน tzdata สามรุ่น ตารางนี้จึงพิมพ์รุ่นของทุกภาษา เพราะคำตอบของคอร์สนี้ขึ้นกับข้อมูลของโซนด้วย ไม่ได้ขึ้นกับ code อย่างเดียว
วันส่งของที่ไปอยู่อีกวัน
หัวข้อที่มีชื่อว่า “วันส่งของที่ไปอยู่อีกวัน”สอง service ที่เก็บวันส่งของค่าเดียวกัน ได้คนละ instant และ service ที่สามอ่านมันเป็นคนละวัน
กลับไปที่ใบเสร็จ สมมุติ service ที่ร้านเขียนด้วย Java แปลงวันส่งของด้วยโซนของร้าน
มันเก็บ 2026-10-01T17:00:00Z เป็นจุดเริ่มวัน
service ของทีมจัดรถเขียนด้วย Node แล้วส่ง "2026-10-02" เข้า new Date ตรงๆ มันเก็บ
2026-10-02T00:00:00.000Z ไม่ว่าจะรันที่ TZ=America/New_York หรือ TZ=Asia/Bangkok
วันส่งของค่าเดียวกันจึงกลายเป็นสอง instant สอง service ที่เทียบวันส่งของด้วย instant เห็นค่าที่ไม่เท่ากัน ทั้งที่ใบเสร็จพิมพ์วันเดียวกัน และไม่มี service ไหนแดง
ส่วน service ที่สามรับค่าของ Java ไปแล้วตัดส่วนวันที่ของ 2026-10-01T17:00:00Z มาแสดง
สิ่งที่ได้ไม่ใช่ 2026-10-02 ลูกค้าเห็นวันส่งของเป็นวันก่อนหน้าบนหน้าจอหนึ่ง และเห็นวันที่ถูกบนใบเสร็จ
ทางแก้ที่ดูง่ายที่สุดคือต่อ T00:00 ท้ายสตริงก่อนส่งเข้า Node ผลที่วัดได้ขึ้นกับ TZ ของ process:
- ที่
TZ=Asia/Bangkokได้2026-10-01T17:00:00.000Zตรงกับจุดเริ่มวันของร้าน - ที่
TZ=America/New_Yorkได้2026-10-02T04:00:00.000Zซึ่งเป็นจุดที่สาม
code บรรทัดเดียวกันจึงถูกบนเครื่องหนึ่งและผิดบนอีกเครื่องหนึ่ง โดยที่ code ไม่ได้บอกเลยว่ามันพึ่ง TZ
ทางออกคือเก็บวันส่งของเป็นวันที่ล้วนคู่กับชื่อโซนของร้าน แล้วแปลงเป็น instant เฉพาะจุดที่ต้องเทียบกับเวลาจริง
โดยส่งโซนเข้าไปเองทุกครั้ง แบบที่ atStartOfDay(bkk) ของ Java กับ Bangkok.from_local_datetime ของ Rust ทำ
ราคาคือทุกจุดที่แปลงต้องรู้ชื่อโซน และ Date ของ Node ไม่มีรูปที่เป็นวันที่ล้วน
สตริง 2026-10-02 จึงต้องอยู่เป็นสตริงจนถึงจุดที่รู้โซน ห้ามส่งมันเข้า new Date ก่อนหน้านั้น
จุดที่สองในหกจุด · วันที่ที่ไม่มีเวลา
หัวข้อที่มีชื่อว่า “จุดที่สองในหกจุด · วันที่ที่ไม่มีเวลา”บทนี้ปิดจุดที่สอง คือวันที่ที่ไม่มีเวลาไม่ใช่จุดบนเส้นเวลา จนกว่าจะมีใครเลือกโซนให้มัน
บทที่ 1 วัดเวลาบนหน้าปัดที่ไม่มีจุดบนเส้นเวลาเลย บทนี้วัดค่าที่สั้นกว่านั้น คือวันที่ที่ไม่มีทั้งเวลาและโซน และมันก็ยังไม่ใช่จุดบนเส้นเวลา
ในทรงที่ probe วัด สิ่งที่แยกห้าภาษาออกจากกันคือใครเป็นคนเลือกโซน:
- Node กับ Go เลือก UTC ให้โดยไม่ถาม และ Node เปลี่ยนไปใช้โซนของ process ทันทีที่สตริงมี
T00:00 - Python ไม่เลือกให้ และคืน naive datetime ที่ยังไม่ใช่ instant
- Java กับ Rust ได้ instant จากโซนที่ผู้เรียกส่งเข้าไปเอง
การเรียกเดียวที่คืน error ในทรงนี้คือ parse_from_rfc3339 ของ Rust ซึ่งเอกสารของ chrono อธิบายว่ามัน parse
RFC 3339 date-and-time string การเรียกอื่นทุกตัวคืนค่ากลับมาให้เงียบๆ
บทที่ 3 ถามต่อจากตรงนี้ ถ้าสตริงที่มากับสายมี offset ติดมาแล้ว offset นั้นแทนโซนได้หรือไม่
-
ECMAScript — Date Time String Format (เข้าถึง 2026-10-03) — ที่มาของข้อที่รูปแบบสตริงของ
Date“is adapted from the ISO 8601 calendar date extended format” และของการแบ่ง date-only forms กับ date-time forms -
ECMAScript — Date.parse ( string ) (เข้าถึง 2026-10-03) — ที่มาของกฎ “When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.”
-
ECMAScript — Date ( …values ) (เข้าถึง 2026-10-03) — ที่มาของข้อที่
new Dateparse สตริง “in exactly the same manner as for the parse method” -
Go — package time, func Parse (เข้าถึง 2026-10-03) — ที่มาของประโยค “In the absence of a time zone indicator, Parse returns a time in UTC.”
-
Python — datetime, Aware and Naive Objects (เข้าถึง 2026-10-03) — ที่มาของนิยามของ naive object
-
Java SE 25 — LocalDate (เข้าถึง 2026-10-03) — ที่มาของนิยามของ
LocalDateและของatStartOfDay(ZoneId) -
chrono 0.4.45 — DateTime::parse_from_rfc3339 (เข้าถึง 2026-10-03) — ที่มาของข้อที่
parse_from_rfc3339อ่าน date-and-time string -
ค่าทุกค่าในบทนี้มาจาก
scripts/time-semantics/measured.jsonซึ่งscripts/time-semantics/probe.pyเขียนเมื่อ 2026-10-02 บน darwin/arm64 · probe วัดทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง และ Node รันด้วยTZที่ probe ตั้งเองทุกครั้ง
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3Node ที่ `TZ=Asia/Bangkok` รับสตริง `2026-10-02` เข้า `new Date` แล้วสั่ง `toISOString()` ได้อะไร