Swift is statically typed: every value has a type, and the compiler checks how values move through declarations, expressions, functions, and collections.
Inference doesn’t perform implicit numeric conversions or make unsafe input valid; array subscripts and forced optional unwrapping can still trap at runtime.
Prefer let, convert at explicit boundaries, validate optional results with if let or guard let, and choose a collection whose ordering and uniqueness match the problem.
What it is and why it exists
Swift’s fundamentals are the rules that connect names, values, types, and control flow.
A declaration binds a name with let or var; its type determines which operations are legal.
Functions package those operations, while arrays, sets, and dictionaries organize groups of typed values.
Swift is statically and strongly typed.
The compiler knows the type of every expression before the program runs and rejects mismatched operations, such as adding an Int directly to a Double.
This catches a large class of mistakes near their source, but it doesn’t prove that a collection index is in range or that an optional contains a value.
Most declarations use type inference .
In let retryLimit = 3, the integer literal and its context let the compiler infer Int; the declaration still has one fixed type.
Write an annotation such as let timeout: Double = 3 when the intended type isn’t obvious from the initializer or when an API contract should be visible.
Use let for a binding that won’t be assigned a different value and var when reassignment is part of the algorithm.
This distinction makes mutation visible during review.
It describes the binding, however, not deep immutability of every object reachable through a class reference.
You meet these rules in every Swift program, from a command-line script to an app. Input arrives as text or optional data, code converts and validates it, control flow chooses a path, and typed collections carry the result to the next layer.
How it works
A Swift declaration has a name, a type, and a value before it is read.
An initializer can supply both the value and enough context for inference.
A declaration without an initializer needs an explicit type and must be initialized on every path before use; assigning a let once later is legal when the compiler can prove it happens exactly once.
The common scalar types are Int, Double, Bool, String, and Character.
Integer types model whole numbers within fixed bounds, floating-point types approximate real numbers, and Bool is accepted directly by conditions.
Swift doesn’t treat 0, an empty string, or nil as false.
Numeric types do not mix implicitly.
Construct the destination type at the point where units and precision have been considered, such as Double(itemCount) * unitPrice.
Parsing has a different failure mode: Int("12") returns Int? because arbitrary text may not contain a valid integer.
An optional value , written Wrapped?, contains either a wrapped value or nil.
Optional binding with if let opens one branch only when a value exists.
guard let is useful when the rest of a function requires the value, because its else branch must leave the current scope.
Swift’s main collection types express different contracts:
Array<Element>keeps an ordered sequence and permits duplicates.Set<Element>keeps unique hashable elements and has no application-level ordering contract.Dictionary<Key, Value>associates unique hashable keys with values; a lookup returns an optional because a key may be absent.
All elements in one collection have the declared element type.
Literal context often supplies it, as in let ports: [Int] = [80, 443].
An empty literal needs context: use [String]() for an empty array, Set<String>() for an empty set, and [String: Int]() or [:] with an annotation for an empty dictionary.
Conditions and loops compose the operations.
if and switch choose among paths, for-in visits a sequence, and while repeats while a Boolean condition remains true.
A switch must be exhaustive, so every possible input reaches a branch or a deliberate default.
Functions give a typed name to a calculation.
Parameters and return values are part of the function’s type, and parameters are constants unless declared inout.
Swift uses an argument label by default; writing _ before a parameter name omits the label at the call site.
The compiler checks this model from the inside out. Literals and surrounding context establish types, operators constrain their operands, and a function call must match its parameter and result types. Runtime checks remain at data-dependent boundaries such as parsing, integer arithmetic, collection indexing, and optional unwrapping.
Examples
Bind values and make a decision
This checkout keeps money in integer cents, avoiding an accidental mix of integer quantities and floating-point prices.
The compiler infers the local types.
Only subtotal uses var because the discount branch reassigns it.
// # not executed here: Swift toolchain is not installed.
let unitPrice = 125
let quantity = 3
var subtotal = unitPrice * quantity
let hasMemberDiscount = true
if hasMemberDiscount {
subtotal -= 25
}
let shipping = subtotal >= 300 ? 0 : 40
let total = subtotal + shipping
print("items: \(quantity)")
print("subtotal: \(subtotal)")
print("shipping: \(shipping)")
print("total: \(total)")Not executed here: Swift toolchain is not installed.The ternary expression gives shipping one value from two branches, so that binding can remain a constant.
All arithmetic stays in Int, and the names preserve the unit decision.
In production money code, use an integer minor unit or a domain type rather than an unqualified Double.
Parse boundary data without forcing it
Dictionary lookup and integer parsing both return optionals.
parseSeats(_:) uses one guard to require the field, parse it, and enforce the accepted range before returning a plain Int.
// # not executed here: Swift toolchain is not installed.
func parseSeats(_ fields: [String: String]) -> Int? {
guard
let rawSeats = fields["seats"],
let seats = Int(rawSeats),
(1...8).contains(seats)
else {
return nil
}
return seats
}
let requests = [
["name": "Mina", "seats": "4"],
["name": "Noah", "seats": "many"],
["name": "Iris"],
]
for request in requests {
let name = request["name", default: "anonymous"]
if let seats = parseSeats(request) {
print("\(name): \(seats)")
} else {
print("\(name): invalid")
}
}Not executed here: Swift toolchain is not installed.The caller can’t distinguish a missing field from malformed or out-of-range text because this small function deliberately maps all three cases to nil.
Use a result or throwing function when the caller needs different recovery or diagnostics.
Invalid input therefore never reaches the booking calculation as a numeric seat count.
Choose collections by their contract
An array preserves the incoming tag order and duplicates.
A set removes duplicates, then sorted() creates a predictable array for display.
The dictionary stores a derived value for each unique tag.
// # not executed here: Swift toolchain is not installed.
let rawTags = ["swift", "ios", "swift", "testing", "ios"]
let uniqueTags = Set(rawTags)
let displayTags = uniqueTags.sorted()
let lengths = Dictionary(
uniqueKeysWithValues: displayTags.map { tag in
(tag, tag.count)
}
)
for tag in displayTags {
print("\(tag): \(lengths[tag, default: 0])")
}
let longTags = displayTags.filter { $0.count >= 5 }
print("long: \(longTags.joined(separator: ", "))")Not executed here: Swift toolchain is not installed.The explicit sort is part of the output contract; iteration over uniqueTags would not provide a meaningful order to callers.
Dictionary(uniqueKeysWithValues:) requires every key to be unique.
That precondition is satisfied here because the pairs come from a set, but generated code often applies the initializer to unchecked input and traps on duplicates.
Observe value semantics
Swift arrays and dictionaries have value semantics . A function can copy an input collection into a local variable, mutate that local value, and return it without changing the caller’s original value.
// # not executed here: Swift toolchain is not installed.
func addingPriority(to jobs: [String]) -> [String] {
var result = jobs
result.insert("incident", at: 0)
return result
}
let queuedJobs = ["backup", "report"]
let priorityJobs = addingPriority(to: queuedJobs)
print("queued:", queuedJobs.joined(separator: ", "))
print("priority:", priorityJobs.joined(separator: ", "))
var profile = ["theme": "light"]
let savedProfile = profile
profile["theme"] = "dark"
print("current:", profile["theme", default: "missing"])
print("saved:", savedProfile["theme", default: "missing"])Not executed here: Swift toolchain is not installed.queuedJobs stays unchanged even though result started with the same elements.
The same rule separates profile and savedProfile after the mutation.
Swift’s standard collections may share internal storage until a write, but that optimization does not change their observable value behavior.
Pitfalls
Fix: choose one stable type for each declaration. Add an explicit annotation when literal inference could hide the intended representation, and convert incoming values at a named boundary rather than scattering casts through the algorithm.
Fix: decide which type owns the calculation and convert once after validating the source.
Use Int(exactly:) when a conversion must preserve the value exactly, and keep unit names such as priceInCents visible in identifiers.
Fix: prefer iteration, first, or last when they express the task.
When an external integer really selects an element, check array.indices.contains(index) before subscripting; unwrap parsing and dictionary lookup before the bounds check.
Fix: sort keys or values explicitly whenever order is observable, including snapshots, command output, encoded data with ordering requirements, and tests. Keep the unordered collection when uniqueness or lookup is the real need.
Fix: iterate over Character values or use String.Index operations.
Choose the correct lower-level view, such as utf8, only when a protocol requires code units, and don’t confuse byte count with displayed character count.
Value semantics are observable
Swift’s standard String, Array, Set, and Dictionary types are values.
Assignment, argument passing, and returns produce logically independent values, so mutating one variable does not mutate another value copied from it.
User-defined structures and enumerations follow the same model, though a value can still contain a reference to a shared class instance.
This is an interface guarantee, not a claim that every assignment immediately duplicates every byte.
Think in terms of observable mutation: if let snapshot = current captures a collection value, later structural changes to current do not change snapshot.
If both collections contain the same mutable class instances, mutations inside those instances remain shared because the elements themselves are references.
Function signatures use this model by default.
A collection parameter is a value passed into the function, and the parameter name cannot be reassigned or mutated directly.
Copy it into a local var and return the new value, or use inout when the API intentionally mutates the caller’s storage and that ownership is clear at the call site.
Copy-on-write is an optimization
Standard library value types can use copy-on-write to share storage while no mutation needs a unique buffer. On mutation, the implementation preserves value semantics by making storage unique when necessary. The fourth example therefore behaves as if the collections were independent even if they shared backing storage before the write.
Do not use copy-on-write as a synchronization story. Two tasks mutating the same variable still create shared mutable state, and implementation details about buffer sharing are not an API guarantee. Measure a real workload before making performance claims or replacing a clear value with manual reference storage.
Nested values require attention to their elements. Copying an array of structures gives independent element values after mutation, while copying an array of class references copies the references. Review the full object graph before claiming that a snapshot is isolated.
String indices follow characters
A Swift Character represents an extended grapheme cluster , which may contain one or many Unicode scalars.
That model lets iteration follow user-perceived characters more closely than byte iteration.
It also means there is no constant mapping from an arbitrary integer offset to a character boundary.
String.Index records a valid position in a particular string view.
Obtain indices from the string itself with operations such as startIndex, endIndex, index(after:), or index(_:offsetBy:); validate reused or external positions before subscripting.
Traversing to a distant character may require work proportional to the distance, so repeated offset-based indexing is the wrong shape for a full scan.
Choose a view according to the boundary you are implementing.
Use Character iteration for text-facing behavior, unicodeScalars when scalar identity matters, and utf8 for byte-oriented formats.
Normalization, locale-sensitive comparison, and display width are separate concerns; count alone does not answer all three.
Integer arithmetic has boundaries
Swift’s fixed-width integer types have minimum and maximum values. Ordinary arithmetic that exceeds those bounds traps instead of silently wrapping in a release build. Choose a type that covers the domain, validate untrusted operands before calculation, and test the largest accepted values together rather than one at a time.
The overflow operators &+, &-, and &* wrap deliberately.
They belong in algorithms whose contract is modular arithmetic, not as a patch for an unexplained overflow.
Generated code sometimes substitutes them to stop a crash and quietly changes an invalid transaction into an apparently valid total.
Ranges also encode boundary choices.
1...8 includes both endpoints, while 1..<8 excludes the upper endpoint.
Empty or reversed ranges require care, and collection slicing should derive boundaries from that collection instead of assuming zero-based integer positions work for every collection type.
Optional defaults define policy
The nil-coalescing operator evaluates its right side only when the optional on the left is nil.
This is concise when absence genuinely means a domain default, such as a missing display nickname becoming "Anonymous".
It is misleading when nil covers malformed or rejected input and the fallback looks like a successful value.
Optional chaining stops at the first absent link and makes the overall result optional.
That behavior is useful for a read whose absence can flow outward.
A chain is less useful when each missing link needs a different message, because the final nil no longer says which assumption failed.
Keep the reason for absence until the layer that owns the fallback decision.
A small parser may intentionally return nil for every invalid form; a request boundary may need an error type that separates a missing field, bad syntax, and a disallowed value.
The type should preserve as much information as its caller needs, no more and no less.
Scope and files shape names
Braces create local scopes for functions, loops, and branches.
A declaration can shadow an outer name, but repeated generic names such as value or result make logs and reviews difficult once scopes nest.
Prefer names that retain the domain and unit, especially at conversion boundaries.
Swift permits declarations at file scope, and a command-line source file can execute top-level statements. Larger programs normally keep boundary work near an entry point and move calculations into functions with explicit parameter and result types. This makes malformed input tests independent from process startup and console output.
Access control is another part of the declaration contract.
A helper that only supports one file can be private or fileprivate, while module-facing declarations may use internal, Swift’s default.
Do not expose a declaration merely because generated code put every helper at top level.
Review a basic Swift file in this order:
Identify every external string, optional field, numeric unit, and collection-order assumption. 2. Check that each binding has the intended static type and the narrowest necessary mutability. 3. Follow every invalid input to an explicit rejection, fallback, or propagated failure. 4. Inspect observable output for deterministic ordering and correct Unicode boundaries.
The compiler proves type compatibility, definite initialization, and exhaustive switches. The author still decides which inputs are trusted, which defaults are truthful, and which mutations belong to shared state. With those decisions visible in source, compiler diagnostics cover type mistakes and tests can cover the remaining data-dependent paths.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug