Skip to content
C.W.K.
Stream
Lesson 06 of 06 · published

Strings, Dates, and Bytes That Look Equal

~16 min · swift-for-apple, unicode, dates, iso8601, data

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Three times in one day the obvious guess about Foundation was backwards. Each time, measuring cost a ten-line script."

Swift Strings Compare Meaning, Files Compare Bytes

Swift's String == uses Unicode canonical equivalence. The Korean word 한글 written precomposed (NFC, 6 UTF-8 bytes) and decomposed into jamo (NFD, 18 bytes) are equal Strings. That is lovely for comparing text a person typed, and dangerous the moment a string is on its way to becoming a filename, a hash input or a dictionary key the file system will see. macOS can hand you a decomposed name from a directory listing while the text field hands you a composed one; hash both and a note grows two sidecar folders with half its history in each. Nothing errors. A family editor porting a filename-key function from Rust learned this when its own "these must differ" test failed — in Swift they compare equal. Normalize explicitly (precomposedStringWithCanonicalMapping is NFC) and compare utf8 bytes when bytes are what matter.

Two Valid Spellings of One Moment

A web client stamped captures as 2026-09-10T12:17:29+09:00. A native client stamped 2026-09-10T10:45:13.146Z. Both are correct ISO 8601. The list sorted the raw strings, so a capture from 19:45 local time sank below one from 12:17, and the newest entry — the entire point of a capture app — looked like it never arrived. Parse to Date before comparing, and know that one ISO8601DateFormatter configured with fractional seconds refuses the plain form and vice versa, so try both. Sort anything unparseable to the bottom on purpose, so a bad timestamp cannot pose as the newest row.

A related trap: a location fix's timestamp is an instant in UTC, while a capture's own time carries the wall-clock offset a person lives in. A label built from the fix read "13:25" for something the screen elsewhere called "22:25". Two fields, two jobs — render the one a human means.

Invisible Characters and Empty-but-Present Data

  • Dictation can leave U+FFFC behind. Dictating into a SwiftUI TextField(axis: .vertical) on iOS left an OBJECT REPLACEMENT CHARACTER at the front of the bound string. A language model on the other end read it as an image placeholder and went hunting for an attachment. Strip U+FFF9 through U+FFFC from text that leaves the device.
  • "Available" is not "present". A pasteboard item that promises a PNG answers data(forType:) with 0 bytes, not nil. A guard written as "nil means incomplete" never trips on a promise. Check emptiness when emptiness is the failure.

Code

Equal strings with different bytes, and two ISO 8601 spellings·swift
import Foundation

// NFC vs NFD
let composed = "한글"
let decomposed = composed.decomposedStringWithCanonicalMapping
print(composed == decomposed)                              // true
print(Array(composed.utf8) == Array(decomposed.utf8))      // false (6 vs 18 bytes)
let fileKey = decomposed.precomposedStringWithCanonicalMapping   // normalize before bytes matter

// Two valid spellings of time
let native = "2026-09-10T10:45:13.146Z"      // 19:45 in Seoul
let web = "2026-09-10T12:17:29+09:00"        // 12:17 in Seoul
print(native < web)                          // true — text order says the newer one is older

enum CaptureClock {
    // One formatter with fractional seconds refuses "...13Z"; one without refuses "...13.146Z".
    static func instant(_ text: String) -> Date? {
        let fractional = ISO8601DateFormatter()
        fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return fractional.date(from: text) ?? ISO8601DateFormatter().date(from: text)
    }
}

print(CaptureClock.instant(native)! > CaptureClock.instant(web)!)   // true — compare instants, not strings

// Text leaving the device
func stripObjectReplacement(_ text: String) -> String {
    String(String.UnicodeScalarView(text.unicodeScalars.filter { !(0xFFF9...0xFFFC).contains($0.value) }))
}

External links

Exercise

Add a CaptureClock to Spark with one function that reads both ISO 8601 spellings and a comparator that sorts newest first with unparseable stamps last. Write tests with the two example strings from the code block plus one garbage string. Then add a fileKey(for:) function that NFC-normalizes a title and prove with a byte comparison that composed and decomposed input produce the same key.
Hint
Enumerate the four comparator cases explicitly: both parse, only left parses, only right parses, neither parses. Leaving nil to fall wherever it falls is how a broken timestamp ends up at the top of a newest-first list.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.