Why Swift won't let you write `string[0]`
Every developer coming to Swift from another language hits this in the first week:
let name = "Swift"
let first = name[0] // error: 'subscript(_:)' is unavailable
It reads like the language being difficult on purpose. It is not — it is the only design that does
not silently corrupt text, and understanding why makes the whole String API stop feeling awkward.
What a character actually is
A Swift Character is a grapheme cluster — what a human reads as one character, which may be
several Unicode scalars, which may be many bytes.
let flag = "🇻🇳"
flag.count // 1 — one Character
flag.unicodeScalars.count // 2 — two regional indicators
flag.utf8.count // 8 — eight bytes
The Vietnamese flag is two Unicode scalars (regional indicators V and N) which the system composes into one glyph. There is no meaningful sense in which it has a “first half”.
The same applies to accented characters, which is why this matters for Vietnamese specifically:
let a = "ế" // one precomposed scalar
let b = "ế" // e + combining circumflex + combining acute
a == b // true — Swift compares canonically
a.count == b.count // true, both 1
a.unicodeScalars.count // 1
b.unicodeScalars.count // 3
Two strings that look identical, are equal, have the same count, and occupy different numbers of
bytes. In a language where string[3] means “the fourth byte”, these two behave differently — which
is exactly the class of bug Swift is preventing.
Why the index is opaque
Since a Character is variable-width, finding the nth one means walking from the start. An Int
subscript would be O(n) while looking O(1), and Swift’s collection design refuses to hide that.
String.Index is an opaque position that already knows where it points:
let name = "Swift"
let first = name[name.startIndex] // "S"
let second = name[name.index(after: name.startIndex)] // "w"
let third = name[name.index(name.startIndex, offsetBy: 2)] // "i"
let last = name[name.index(before: name.endIndex)] // "t"
Verbose, and honest about the cost.
Warning
An index belongs to the string it came from. Using an index from one string on another is undefined behaviour and may crash — and mutating a string invalidates its indices. This is the part that catches people writing loops that modify as they go.
What to write instead
Most of the time you do not need an index at all, and the code is better without one.
// first / last
name.first // Character?, safe
name.last
// prefix / suffix
name.prefix(3) // "Swi"
name.suffix(2) // "ft"
name.dropFirst() // "wift"
// searching
if let range = text.range(of: "Swift") {
text.replaceSubrange(range, with: "Rust")
}
// splitting
"a,b,c".split(separator: ",") // ["a", "b", "c"]
// iterating with position
for (offset, character) in name.enumerated() { … }
enumerated() gives you a position without an index, and it is what most for i in 0..<count loops
actually wanted.
The extension people write, and why not to
Everyone eventually writes this:
extension String {
subscript(index: Int) -> Character {
self[self.index(startIndex, offsetBy: index)]
}
}
It works and I would not ship it. Two reasons: it is O(n) while looking O(1), so
for i in 0..<s.count { s[i] } is quietly O(n²); and it traps rather than returning nil if the index
is out of range, so it is less safe than the API it replaces.
If you genuinely need integer positions — implementing a parser, porting an algorithm — convert once:
let characters = Array(name) // O(n) once
let third = characters[2] // O(1) from here
One allocation and the indices behave the way the algorithm expects.
The one that matters for Vietnamese
Comparison is canonical, which is almost always what you want:
"Việt" == "Việt" // true, regardless of composition
But byte-level operations are not:
let composed = "ế" // 3 bytes
let decomposed = "ế" // 6 bytes
composed.utf8.count == decomposed.utf8.count // false
If you are storing strings in a database, generating a checksum, or matching against a server value, normalise first:
let normalised = text.precomposedStringWithCanonicalMapping
This is the bug behind “the search does not find the word I can see on the screen” — the user typed a decomposed form and the database has a precomposed one, and every comparison at the byte level disagrees while every comparison in Swift agrees.
The conclusion
The API is verbose because the problem is genuinely hard, and every language that makes s[0] easy
has quietly decided that “character” means “byte” or “UTF-16 code unit”. That is fine until someone
types an emoji, an accented Vietnamese vowel, or any of a dozen scripts where it produces mojibake.
Swift chose to make the awkwardness visible instead. Having debugged the alternative in another language, I would take this trade again.