A production-ready Swift Package for picking Cambodian administrative addresses — Province → District → Commune/Sangkat → Village — with offline search, bilingual place names, and optional remote sync. Zero third-party dependencies.
| Offline-first | Full NCDD dataset bundled (~1.3 MB). Works in airplane mode, no backend required. |
| Fast search | Prefix + typo-tolerant fuzzy search across all 14,578 villages in Khmer and English. |
| Bilingual | Place names in Khmer (ភ្នំពេញ) and English (Phnom Penh), with arbitrary extra locales and graceful fallback. |
| SwiftUI + UIKit | Drop-in binding picker, standalone screen, and a UIHostingController-backed view controller. |
| GPS → commune | AddressGeoService resolves a GPS coordinate to the nearest commune using bundled centroids + haversine. MapAddressPicker (iOS 18+) lets the user tap the map to confirm. |
| Validation | AddressValidator checks completeness, NCDD code format, and parent-child consistency in one pass — returns every ValidationIssue at once. |
| Postal codes | PostalCode derives a 5-digit Cambodia Post code from any province + district selection. |
| Remote sync | Refresh the dataset from your own HTTPS endpoint. Offline-first: the update lands on the next launch. |
| Swift 6 concurrency | Full strict concurrency — actor store, @MainActor view models, Sendable everywhere. No data races. |
| Modular | Six targets in a strict dependency line. Depend only on CambodiaAddressCore for headless/server use. |
| Tested | 168 unit + integration tests, no third-party test dependencies. |
- iOS 18+
- Swift 6 / Xcode 16+
Xcode: File → Add Package Dependencies… and enter:
https://github.com/NemSothea/CambodianAddressSDK.git
Package.swift:
dependencies: [
.package(url: "https://github.com/NemSothea/CambodianAddressSDK.git", from: "2.0.0")
],
targets: [
.target(name: "MyApp", dependencies: [
// Full picker + map: SwiftUI/UIKit + GPS + validation
.product(name: "CambodiaAddress", package: "CambodianAddressSDK"),
// GPS only (no SwiftUI/UIKit, works on macOS/Linux/server):
// .product(name: "CambodiaAddressGeo", package: "CambodianAddressSDK"),
])
]Headless / server use? Depend on
CambodiaAddressCoreinstead — no SwiftUI/UIKit, Foundation only, includesAddressValidatorandPostalCode.GPS without MapKit? Depend on
CambodiaAddressGeo— pure Foundation, works on macOS and Linux.
Three lines to get a working address picker:
import SwiftUI
import CambodiaAddress
struct ContentView: View {
@State private var address = AddressSelection()
var body: some View {
CambodiaAddressPicker(selection: $address)
.addressLanguage(.khmer)
}
}address updates as the user picks each level. Access the result:
address.province?.name.en // "Phnom Penh"
address.district?.name.km // "ដូនពេញ"
address.isComplete // true once village is chosenimport CambodiaAddress
struct CheckoutView: View {
@State private var address = AddressSelection()
var body: some View {
Form {
CambodiaAddressPicker(selection: $address)
.addressLanguage(.khmer) // .english · .system (follows device locale)
}
}
}The binding is two-way: you can pre-seed address with a saved selection and the picker will rehydrate its child lists automatically.
Button("Pick address") {
showPicker = true
}
.sheet(isPresented: $showPicker) {
AddressPickerView { address in
save(address)
showPicker = false
}
}let vc = CambodiaAddressPickerViewController { address in
print(address.province?.name.en ?? "") // "Phnom Penh"
}
present(vc, animated: true)Use the facade directly when building custom forms, running validation, or on a server:
let cambodia = CambodiaAddress.live()
let provinces = try await cambodia.provinces()
let districts = try await cambodia.districts(inProvince: "12")
let results = try await cambodia.search("ដូនពេញ", limit: 10)Inject once at the root; all child views inherit the repository and language:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.cambodiaAddress(.live(.init(language: .khmer, searchLimit: 20)))
}
}
}AddressGeoService resolves a GPS coordinate to a full AddressSelection (province + district + commune) using bundled centroids and the haversine formula. Village is intentionally nil — the user confirms it in the picker.
import CambodiaAddressGeo
import CambodiaAddressCore
let geo = AddressGeoService()
let coordinate = Coordinate(latitude: 11.5625, longitude: 104.916)
let selection = try await geo.selection(near: coordinate)
print(selection.province?.name.en ?? "") // "Phnom Penh"
print(selection.commune?.name.km ?? "") // nearest commune in KhmerFeed selection straight into a CambodiaAddressPicker binding so the user can confirm or adjust the village:
.sheet(isPresented: $showPicker) {
CambodiaAddressPicker(selection: $address)
}
.onAppear {
Task { address = try await geo.selection(near: userLocation) }
}MapAddressPicker is a SwiftUI view that lets the user tap anywhere on a map. The status bar shows the resolved commune in real time; tapping Confirm calls back with the full AddressSelection.
import CambodiaAddress // CambodiaAddressUI re-exports MapAddressPicker
@available(iOS 18.0, *)
struct LocationPickerSheet: View {
@Binding var address: AddressSelection
@Environment(\.dismiss) private var dismiss
var body: some View {
MapAddressPicker { selection in
address = selection
dismiss()
}
}
}
MapAddressPickerrequires iOS 18 andCambodiaAddressGeo(automatically pulled in as a dependency ofCambodiaAddressUI).
Search works offline across all four levels, in both Khmer and English. It combines prefix matching and bounded fuzzy (Damerau-Levenshtein distance ≤ 2), so typos like "chamkat" still find "Chamkar Mon".
let results = try await cambodia.search("doun", limit: 10)
for result in results {
print(result.level) // .district
print(result.name.en) // "Doun Penh"
print(result.name.km) // "ដូនពេញ"
print(result.path.province?.name.en ?? "") // "Phnom Penh" ← full breadcrumb
}AddressSearchResult carries the full parent breadcrumb in .path — pass it directly to apply(_:) on the view model to deep-link the picker to that result without extra lookups.
public struct AddressSelection: Codable, Sendable, Hashable {
public var province: Province?
public var district: District?
public var commune: Commune?
public var village: Village?
public var isComplete: Bool // true when village != nil
public var deepestLevel: AdministrativeLevel?
}Selections are value types keyed by stable NCDD codes — safe to persist in UserDefaults, Codable JSON, or CoreData. A dataset update never invalidates a saved selection.
let formatter = AddressFormatter(language: .english)
formatter.string(from: address)
// → "Voat Phnum, Doun Penh, Phnom Penh"
let kh = AddressFormatter(language: .khmer)
kh.string(from: address)
// → "វត្តភ្នំ, ដូនពេញ, ភ្នំពេញ"Pass numerals: .khmer (or .automatic — Khmer digits when the resolved language is Khmer) to render district/village numbers in Khmer script:
AddressFormatter(language: .khmer, numerals: .khmer).string(from: address)
// → "ផ្សារថ្មីទី ៣, …" (Khmer numeral ៣ instead of 3)The bundled dataset is the full NCDD gazetteer — 25 provinces, 210 districts, 1,652 communes/sangkats, and 14,578 villages. To upgrade the dataset, drop a new cambodia_address.json into Sources/CambodiaAddressData/Resources/ — no code changes required.
Wire format:
Codes follow the NCDD convention: province = 2 digits, district = 4, commune = 6, village = 8. A child's code is always prefixed by its parent's.
name.resolved(for: .khmer) // "ភ្នំពេញ"
name.resolved(for: .english) // "Phnom Penh"
name.resolved(for: .system) // follows Locale.currentCustom datasets can carry additional locales via an optional i18n map. Resolution falls back gracefully (fr → en → km):
{ "code": "12", "km": "ភ្នំពេញ", "en": "Phnom Penh", "i18n": { "fr": "Phnom Penh", "zh": "金边" } }name.resolved(for: .locale("zh")) // "金边"
name.resolved(for: .locale("de")) // falls back → "Phnom Penh"Keep the dataset fresh from your own HTTPS endpoint without ever going offline. CachingDataSource is offline-first: it immediately serves the freshest snapshot already on the device, then refreshes from the network in the background — the update is available on the next launch.
let cambodia = CambodiaAddress.live(
.init(dataSource: .synced(URL(string: "https://yourserver.com/cambodia_address.json")!))
)The remote fetch enforces HTTPS-only, a configurable response size cap, and HTTP-status + decode validation. For full control:
let remote = RemoteAddressDataSource(
endpoint: myDatasetURL,
configuration: .init(maximumResponseBytes: 16 * 1024 * 1024) // 16 MB cap
)
let source = CachingDataSource(remote: remote) // falls back to the bundled dataset offlineAddressValidator checks a selection in one pass and returns every ValidationIssue. Checks are divided into three layers: completeness, NCDD code format, and parent-child consistency.
import CambodiaAddressCore
let issues = AddressValidator.validate(address)
if issues.isEmpty {
submit(address)
} else {
for issue in issues {
print(issue.errorDescription ?? "")
}
}Village validation is required by default. Pass requiresVillage: false for commune-level forms:
AddressValidator.validate(address, requiresVillage: false)
AddressValidator.isValid(address, requiresVillage: false) // Bool convenienceValidationIssue cases:
| Category | Cases |
|---|---|
| Completeness | .missingProvince · .missingDistrict · .missingCommune · .missingVillage |
| Code format | .invalidProvinceCode · .invalidDistrictCode · .invalidCommuneCode · .invalidVillageCode |
| Consistency | .districtProvinceMismatch · .communeDistrictMismatch · .villageCommuneMismatch |
PostalCode derives a 5-digit Cambodia Post code from an AddressSelection. The formula is province(2) + districtSuffix(2) + "0".
import CambodiaAddressCore
// From a selection (most common path):
if let code = address.postalCode {
print(code.rawValue) // "12010" (Phnom Penh / Doun Penh)
}
// Direct construction:
let code = PostalCode(province: province, district: district) // "12010"
let prov = PostalCode(province: province) // "12000" province-only
let raw = PostalCode(rawValue: "12010") // validated from a stored stringPostalCode conforms to Codable, Sendable, Hashable, and RawRepresentable — safe to store in UserDefaults or a Codable model.
All async SDK calls throw AddressError. Handle it at the repository boundary:
do {
let provinces = try await cambodia.provinces()
} catch AddressError.resourceNotFound(let name) {
// Bundled JSON missing — should not happen in a correctly configured app
} catch AddressError.decodingFailed(let message) {
// JSON is malformed or the schema is incompatible
} catch AddressError.network(let message) {
// Remote sync failed — fall back to the cached / bundled dataset
} catch AddressError.payloadTooLarge {
// Remote response exceeded the configured size cap
} catch {
// Unexpected error
}AddressPickerViewModel catches all errors internally and exposes them via the errorMessage: String? property, so SwiftUI views don't need explicit try/catch.
CambodiaAddress ← Umbrella facade · composition root
├── CambodiaAddressUI ← SwiftUI picker + UIKit wrapper + MapAddressPicker · @Observable view model
├── CambodiaAddressGeo ← GPS → commune · NearestCommuneFinder actor · haversine (Foundation only)
├── CambodiaAddressData ← Datasources · AddressStore actor · DefaultAddressRepository
├── CambodiaAddressSearch← Khmer normalizer · prefix index · Damerau-Levenshtein fuzzy
└── CambodiaAddressCore ← Domain models · AddressValidator · PostalCode · formatter (Foundation only)
Key design decisions:
- Dependency inversion — UI depends on
AddressRepository(protocol in Core), never on concrete data loading. Swap inInMemoryDataSourcefor tests with zero mock frameworks. - Actor-based caching —
AddressStoreis anactor. The dataset loads exactly once across concurrent callers; all parent→child index dictionaries are built at that point. - Value semantics — every model is an immutable
struct(Codable, Sendable, Hashable). Identity is the stable NCDD code string, never an array index. - No singletons —
CambodiaAddress.live()is a composition root; everything is injected. Use multiple instances for multi-tenant or testing scenarios.
See ARCHITECTURE.md for the full contract and BUILD_PLAN.md for the build phases.
The SDK ships with 168 tests across six targets:
| Target | What's tested |
|---|---|
CambodiaAddressCoreTests |
Model decoding, LocalizedName.resolved, AddressFormatter, selection invariants, AddressValidator, PostalCode |
CambodiaAddressDataTests |
Repository linkage, lazy single-load, concurrency, remote sync, caching, wire format |
CambodiaAddressSearchTests |
Khmer normalization, prefix correctness, fuzzy bounds, ranking, p95 < 16 ms on 25k dataset |
CambodiaAddressUITests |
AddressPickerViewModel state transitions, debounce, stale-result guards, reset cascade |
CambodiaAddressGeoTests |
Haversine identity/symmetry/real-distance, NearestCommuneFinder actor, bundled centroid load, coordinate bounds |
CambodiaAddressTests |
End-to-end facade tests against the bundled NCDD dataset |
Run locally:
swift testNo mocking frameworks — all fakes are hand-written InMemoryDataSource / FakeAddressRepository conformances.
A runnable tab-based showcase lives in ExampleApp/:
| Tab | Shows |
|---|---|
| Picker | Drop-in CambodiaAddressPicker, formatted output, Khmer/English toggle, sheet presentation |
| Search | Live offline search, level filters, result badges, breadcrumb paths |
| UIKit | CambodiaAddressPickerViewController presented modally |
To run it:
cd ExampleApp
xcodegen generate # requires: brew install xcodegen
open CambodiaAddressExample.xcodeprojSee ExampleApp/README.md for details.
Full API reference (DocC) is hosted at: nemsothea.github.io/CambodianAddressSDK
It rebuilds automatically on every push to main.
| Version | Status | What's included |
|---|---|---|
| v1.0–1.0.2 | ✅ Done | Province / District / Commune / Village · offline JSON · search · SwiftUI + UIKit · NCDD dataset · picker concurrency fixes |
| v1.1 | ✅ Done | RemoteAddressDataSource + CachingDataSource · offline-first API sync |
| v1.2 | ✅ Done | Multi-locale place names · arbitrary locales via .locale("fr") with fallback · Khmer-numeral formatting |
| v1.3–1.4 | ✅ Done | DoS hardening · code-review fixes (search/selection parity, async cache I/O, traversal contract) · full README media |
| v2.x | ✅ Done | CambodiaAddressGeo module · NearestCommuneFinder actor · AddressGeoService · MapAddressPicker (SwiftUI + MapKit, iOS 18+) · bundled commune centroids |
| v3.x | ✅ Done | AddressValidator · ValidationIssue · PostalCode · AddressSelection.postalCode |
See the full release history → and CHANGELOG →
This SDK follows Semantic Versioning. The public surface area (types, protocols, and methods exported by the CambodiaAddress and CambodiaAddressCore products) will not have breaking changes within a major version. Internal types (AddressStore, SearchIndex, DatasetDecoding, etc.) are not part of the public API.
Contributions are welcome — bug fixes, dataset updates, and new locales especially.
- Fork the repo and create a feature branch.
- Run
swift test— all 168 tests must pass with zero warnings under Swift 6 strict concurrency. - Follow the conventions in
ARCHITECTURE.md: no third-party deps in shipping targets, nofatalErrorstubs, public API gets///doc comments. - Open a pull request against
main.
For significant changes, open an issue first to discuss the approach.
The bundled administrative dataset is derived from pumi (MIT licensed), which compiles geodata from the NCDD (National Committee for Sub-National Democratic Development) gazetteer — http://db.ncdd.gov.kh/gazetteer. Khmer names, romanized names, and NCDD codes originate there.
MIT — see LICENSE.
The bundled dataset derives from pumi (MIT) / NCDD. See THIRD_PARTY_NOTICES.md and Acknowledgements above.








{ "version": "2026.06", "provinces": [{ "code": "12", "km": "ភ្នំពេញ", "en": "Phnom Penh" }], "districts": [{ "code": "1201", "p": "12", "km": "ដូនពេញ", "en": "Doun Penh", "t": "khan" }], "communes": [{ "code": "120103", "d": "1201", "km": "ផ្សារថ្មីទី ៣", "en": "Phsar Thmei Ti Bei", "t": "sangkat" }], "villages": [{ "code": "12010301", "c": "120103", "km": "...", "en": "..." }] }