To track text appended to a Swift string, store offsets in a named encoding instead of retaining old string indices. This guide builds an append-only UTF-16 segment ledger for a small local Mac text-preview calculation.

The task is specific: join synthetic text fragments, remember where each fragment belongs, and recover its exact contents after later appends. The example operates entirely in memory and prints its results. Its segment boundaries describe contributions to the text, which can differ from the boundaries between displayed characters.

Why saving the old end index is unreliable

A tempting approach saves text.endIndex, appends a fragment, and pairs that saved index with the new text.endIndex. That mixes an index obtained before mutation with a string obtained afterward. Swift’s collection documentation explicitly warns that mutating operations can invalidate saved indices. A successful result with a short sample does not establish a general contract for later mutations. See Apple’s Collection documentation.

There is a second issue even if you reconstruct indices: appending text can change the character boundary at the join. Swift’s language guide demonstrates this with "cafe" followed by "\u{0301}", a combining acute accent. The resulting text still contains four Swift Character values because the accent joins the preceding letter. See Strings and Characters.

Consequently, recording the old character count and advancing that many characters into the finished string does not reliably recover the start of an appended fragment. In the accent example, the appended contribution starts inside the final grapheme cluster. There is no separate displayed character corresponding to that contribution.

Treat an append boundary and a character boundary as separate concepts. The first records how the text was assembled; the second describes how Swift groups the assembled text for character-level operations.

Which coordinates should the ledger store?

For this Mac calculation, use integer UTF-16 offsets and lengths. Apple documents that NSString expresses its string positions and ranges in UTF-16 code units. Swift’s String.utf16 view exposes matching code units, so the ledger can use one coordinate system for both measurement and Foundation substring extraction. See NSString and String’s UTF-16 view.

Reader task Appropriate representation Condition
Recover each original fragment independently An array of owned String values No combined-text coordinates are needed
Locate exact contributions in combined text UTF-16 offsets and lengths Earlier text remains unchanged
Work with displayed characters Fresh character indices in the current string Segment boundaries must be reconsidered as grapheme boundaries
Preserve positions through arbitrary edits An edit-aware position model This append-only ledger is insufficient

The ledger below has four explicit rules:

  1. It starts with an empty string.
  2. Its only text mutation is appending a complete Swift String.
  3. Every append records the previous UTF-16 total and the fragment’s UTF-16 length.
  4. A segment number is meaningful only with the ledger instance or snapshot that produced it.

These rules make the offsets useful. Appending a suffix leaves the preceding encoded contents in place, so earlier spans retain their locations. Inserting, removing, replacing, or transforming earlier text would require a different contract. This is a design derived from the documented string representations, rather than an automatic position-tracking feature supplied by Swift.

Build a complete local segment ledger

Use a macOS Swift command-line target with Foundation available. Replace its sample top-level program with the complete program below. The implementation uses append(contentsOf:), whose documented Swift signature accepts another String. See Apple’s append(contentsOf:) reference.

import Foundation

struct Segment: Sendable {
    let utf16Location: Int
    let utf16Length: Int

    var foundationRange: NSRange {
        NSRange(location: utf16Location, length: utf16Length)
    }
}

enum LedgerError: Error {
    case unknownSegment(Int)
}

struct SegmentLedger: Sendable {
    private(set) var text = ""
    private(set) var segments: [Segment] = []
    private var utf16Total = 0

    @discardableResult
    mutating func append(_ fragment: String) -> Int {
        let length = fragment.utf16.count
        let (newTotal, overflow) =
            utf16Total.addingReportingOverflow(length)
        precondition(!overflow, "UTF-16 total exceeds Int capacity")

        let segment = Segment(
            utf16Location: utf16Total,
            utf16Length: length
        )

        text.append(contentsOf: fragment)
        segments.append(segment)
        utf16Total = newTotal
        return segments.count - 1
    }

    func fragment(at segmentID: Int) throws -> String {
        guard segments.indices.contains(segmentID) else {
            throw LedgerError.unknownSegment(segmentID)
        }

        let segment = segments[segmentID]
        if segment.utf16Length == 0 {
            return ""
        }

        return (text as NSString).substring(
            with: segment.foundationRange
        )
    }
}

let inputs = ["cafe", "\u{0301}", "", "\u{1F7E6}", " done"]
var ledger = SegmentLedger()

for input in inputs {
    ledger.append(input)
}

let expectedLocations = [0, 4, 5, 5, 7]
let expectedLengths = [4, 1, 0, 2, 5]

for id in inputs.indices {
    let recovered = try ledger.fragment(at: id)
    let segment = ledger.segments[id]

    // Compare code units, so the check is encoding-exact.
    precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
    precondition(segment.utf16Location == expectedLocations[id])
    precondition(segment.utf16Length == expectedLengths[id])

    print("\(id): location=\(segment.utf16Location), " +
          "length=\(segment.utf16Length)")
}

precondition(ledger.text.utf16.count == 12)
precondition(ledger.text.count == 10)

// Later appends must preserve earlier segment contents.
ledger.append("!")
for id in inputs.indices {
    let recovered = try ledger.fragment(at: id)
    precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
}

// Invalid segment numbers produce an explicit error.
do {
    _ = try ledger.fragment(at: -1)
    preconditionFailure("Expected an unknown-segment error")
} catch LedgerError.unknownSegment(let id) {
    precondition(id == -1)
}

print("All segment checks passed.")

This program was inspected against the retrieved API documentation; it was not executed here because the available shell has no Swift executable. The included preconditions make the sample’s expected behavior explicit when you run it on your Mac.

What the sample is designed to check

The expected segment coordinates are calculated from the literal inputs. The combining accent contributes one UTF-16 code unit, the empty fragment contributes zero, and the blue square contributes two. The final suffix contributes five. These are illustrative input calculations, not performance measurements.

Segment Input UTF-16 location UTF-16 length
0 "cafe" 0 4
1 "\u{0301}" 4 1
2 "" 5 0
3 "\u{1F7E6}" 5 2
4 " done" 7 5

The empty fragment receives its own segment number even though it shares a location with the following segment. This preserves the append history without inventing text. Returning "" explicitly also avoids a Foundation substring call for this case.

The recovery check compares UTF-16 arrays deliberately. Swift string equality recognizes canonical equivalence, meaning some strings with different underlying scalar sequences compare equal. An encoding-exact assertion is more appropriate for this ledger’s contract. See the string comparison discussion in Strings and Characters.

Why extraction uses Foundation

The stored integers are converted into an NSRange and passed directly to NSString.substring(with:). Apple documents that this method extracts the specified range and requires that range to remain within the receiver’s bounds. Invalid ranges can cause an exception. The ledger controls both the text and its spans, and rejects unknown segment numbers before extraction. See substring(with:).

Every recorded boundary comes from a complete Swift string append. The example never manufactures an offset halfway through the blue square’s UTF-16 surrogate pair. A boundary may still lie inside a composed character sequence, as the accent demonstrates; recovering exact contributions is the purpose of this representation.

Do not use String.count to construct these Foundation ranges. Apple defines NSString.length as a UTF-16 code-unit count, which is a different measurement from the character count and does not describe rendered width. See NSString.length.

Keep mutation, segment creation, and snapshots together

For a small synchronous calculation, the value type is enough: one owner appends fragments and reads the result. Its private setters prevent callers from replacing the combined text independently of the segment table. The cached UTF-16 total also avoids recounting the growing combined string for every append.

If several asynchronous tasks must contribute to one ledger, put that ledger behind a single actor, or keep it on @MainActor when it belongs to a Mac interface. Perform the append and metadata update in one synchronous isolated method. Swift documents that actor-isolated synchronous work cannot be interrupted at a suspension point within that method. See Concurrency.

Return the text and segment metadata together as one value snapshot. Avoid separately awaiting a text read and a segment-table read: another append could occur between those calls. The example’s Sendable declarations describe values suitable for crossing isolation boundaries; they do not serialize concurrent writes to a shared variable. Swift’s Sendable documentation explains the distinction between transferable values and protected mutable state.

If contribution order matters, establish that order before appending. An actor protects the ledger’s internal consistency, while the application still needs to decide which fragment comes first.

Know when these offsets stop answering the task

Use this diagnostic sequence before reusing a span:

  1. Check ownership. Does the segment number belong to this ledger or one of its compatible append-only descendants?
  2. Check mutation history. Has anything changed before the recorded span? If so, rebuild the ledger or adopt an edit-aware model.
  3. Check units. Are the consumer’s coordinates UTF-16 code units? Do not substitute character counts or UTF-8 byte counts.
  4. Check the intended result. Are you recovering the exact appended contribution, or selecting a whole displayed character? Those can require different boundaries.

A character-oriented selection cannot always represent only one appended contribution. The accent belongs to the same displayed character as the preceding letter. If the desktop task is highlighting, choose an explicit presentation rule: expand the selection to the containing character, or show the contribution separately in a segment list. Expansion includes text from an earlier segment, so retain the original offsets for exact recovery.

For exact append tracking, the useful invariant is simple: keep the prefix unchanged, name the coordinate units, and keep each span with its text. Under that contract, the ledger can recover earlier contributions after later appends without carrying a single String.Index across mutation.

Sources