Swift String Contains: Check for a Substring (Case-Insensitive Too)

Swift String Contains: Check for a Substring (Case-Insensitive Too)

Written by

in

Quick answer: Call string.contains("Swift") — it returns a plain Bool and needs no NSRange check. When you need case- or diacritic-insensitive matching, use string.range(of: "swift", options: .caseInsensitive) != nil instead of lowercasing the whole string.

Why this happens

The Objective-C pattern you probably have in your head returns an NSRange and then compares location against NSNotFound. That works because NSString is a sequence of UTF-16 code units with integer offsets. Swift’s String is a collection of Character values, and its positions are String.Index values, not integers. The old two-step dance has no direct equivalent, so people reach for containsString() — the Objective-C selector — and get:

Value of type 'String' has no member 'containsString'

Swift imports NSString.containsString: as contains(_:), and the standard library plus Foundation give you a family of overloads on String. Once you know which overload does what, the check is one line.

There are really two tools. contains(_:) answers “is this substring in there?” and returns Bool. range(of:options:) answers the same question but accepts comparison options and hands back the matched range, which you need for highlighting or for case-insensitive matching.

How to fix it

1. Use contains(_:) for a plain substring check

This is the direct replacement for the Objective-C snippet.

import Foundation

let string = "hello Swift"

if string.contains("Swift") {
    print("exists")
}

No optional, no NSNotFound, no unwrapping. Two details worth knowing:

  • The check is case-sensitive. "hello Swift".contains("swift") is false.
  • An empty search string returns true, because the empty string is contained everywhere. Guard against empty input if that matters for your UI.

If the file only imports Swift, add import Foundation — the string-taking contains(_:) overload lives in Foundation’s StringProtocol extension. SwiftUI and UIKit files already have it.

2. Use range(of:options:) when case or accents should not matter

let string = "hello Swift"

if string.range(of: "swift", options: .caseInsensitive) != nil {
    print("exists")
}

Options combine, so you can ignore accents as well:

let name = "Café"
let found = name.range(of: "cafe", options: [.caseInsensitive, .diacriticInsensitive]) != nil
// true

range(of:) returns Range<String.Index>?, so != nil is your existence test. This is the version to prefer over string.lowercased().contains("swift"): lowercasing allocates a whole new string on every call, and it is locale-naive — in a Turkish locale "I".lowercased() is not "i", so a naive lowercase comparison can silently miss matches.

3. Use localizedStandardContains(_:) for search fields

When the string comes from a user typing into a search bar, Foundation has a method built for exactly that case:

let items = ["Café", "Tea", "Coffee"]
let query = "cafe"

let matches = items.filter { $0.localizedStandardContains(query) }
// ["Café", "Coffee"]

It is case-insensitive, diacritic-insensitive and locale-aware, which is what users expect from a search field. It is also shorter than spelling out the option set every time. Pair it with .searchable in SwiftUI or a UISearchController and you get behaviour that matches the rest of the system.

4. Get the matched range when you need to highlight it

If you are building an NSAttributedString to bold the match, convert the Swift range to an NSRange:

let text = "hello Swift"

if let range = text.range(of: "Swift", options: .caseInsensitive) {
    let nsRange = NSRange(range, in: text)
    // use nsRange with NSAttributedString or UITextView
}

Do not compute the NSRange by hand from String.count — grapheme clusters and UTF-16 offsets do not line up.

5. Do not add a contains(find:) extension any more

Older answers define this on String:

extension String {
    func contains(find: String) -> Bool {
        return self.range(of: find) != nil
    }
}

It is redundant now that contains(_:) exists, and the find: argument label does not read like Swift. If you want a convenience for the option-set version, name it for what it does:

extension String {
    func containsIgnoringCase(_ other: String) -> Bool {
        range(of: other, options: .caseInsensitive) != nil
    }
}

6. Watch out for grapheme clusters

String is a collection of Character values, and a Character can be many Unicode scalars. A family emoji is a single Character, so searching for one person inside it finds nothing:

let family = "👨‍👩‍👧‍👦"
family.count                    // 1
family.contains("👨")           // false

If you genuinely need to inspect the pieces, drop to scalars:

let hasMan = family.unicodeScalars.contains { $0.value == 0x1F468 }

For normal text this never comes up. It only bites when you search inside emoji or inside text with combining marks, and it is the reason a contains check can look “wrong” on strings that appear identical on screen.

7. Related checks

  • string.hasPrefix("hello") and string.hasSuffix("Swift") for anchored matches — cheaper and clearer than a range(of:) plus index comparison.
  • string.contains(where: { $0.isNumber }) is a different overload: it tests each Character against a predicate, not a substring.
  • string == "hello Swift" for exact equality. Do not use contains for that.

How to prevent it

  • Reach for contains(_:) first; only move to range(of:options:) when you need options or the matched range.
  • Never call containsString(_:) on a Swift String — that is the Objective-C selector name.
  • Prefer options: .caseInsensitive over lowercased(); it avoids an allocation and locale surprises.
  • Use localizedStandardContains(_:) for anything a user typed into a search field.
  • Convert Swift ranges with NSRange(_:in:) rather than arithmetic on count.
  • Add import Foundation to any file that uses range(of:) or localizedStandardContains(_:).

FAQ

Does String.contains(_:) ignore case?

No. It is a case-sensitive substring check. Use range(of:options: .caseInsensitive) != nil or localizedStandardContains(_:) when case should not matter.

What is the difference between contains(_:) and range(of:)?

Both tell you whether a substring exists. contains(_:) returns a Bool and takes no options; range(of:options:) returns an optional Range<String.Index> and accepts comparison options such as .caseInsensitive and .diacriticInsensitive. Use the second when you need the match location or flexible comparison.

Why does contains("") return true?

Because the empty string is a substring of every string, including the empty string itself. If an empty search field should match nothing, check !query.isEmpty before filtering your collection.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.