Home

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.