SDKs
Swift SDK
Messagevisor's Swift SDK evaluates translations, message overrides and locale formatting in native iOS and iPadOS, macOS, tvOS, watchOS and visionOS applications.
Installation#
Add the package with Swift Package Manager:
dependencies: [ .package( url: "https://github.com/messagevisor/messagevisor-swift.git", from: "0.2.0" )]Add the products your application needs:
.target( name: "YourApp", dependencies: [ .product(name: "Messagevisor", package: "messagevisor-swift"), .product(name: "MessagevisorICU", package: "messagevisor-swift"), ])In Xcode, use File → Add Package Dependencies and enter:
https://github.com/messagevisor/messagevisor-swift.gitPublic API#
The main runtime API is createMessagevisor():
let m: Messagevisor = createMessagevisor( MessagevisorOptions(datafile: datafile))Most applications only need createMessagevisor, Messagevisor, and MessagevisorOptions. Public extension and observability types include MessagevisorModule, MessagevisorDiagnostic, MessagevisorEvent, MessagevisorChild, and the datafile model types.
The core SDK and module products support the Apple platforms listed above. Shared Messagevisor and child state is safe to use from concurrent callers. Module, diagnostic, event, resolver, subscription, and missing translation callbacks are @Sendable; callback implementations must synchronize any mutable state they capture.
Initialization#
Create the main runtime with createMessagevisor():
import Messagevisorlet m: Messagevisor = createMessagevisor( MessagevisorOptions(datafile: datafile))print(try m.translate("auth.signin"))// → "Sign in"The SDK can start without a datafile. This is useful when your application fetches translations after launch:
let m = createMessagevisor( MessagevisorOptions(locale: "en-US"))m.setDatafile(downloadedDatafile)Datafile fetching#
The SDK does not prescribe a network layer. Fetch a target- and locale-specific datafile with URLSession, load one from your application bundle, or use your existing cache/CDN client.
import Foundationimport Messagevisorlet url = URL(string: "https://cdn.example.com/messagevisor/ios/en-US.json")!let (data, _) = try await URLSession.shared.data(from: url)let datafile = try DatafileContent.fromData(data)let m = createMessagevisor( MessagevisorOptions(datafile: datafile))For offline-first applications, bundle an initial datafile and replace or merge fresher content after a successful fetch.
Recommended modules#
Translation lookup and conditional overrides live in the core Messagevisor product. Formatting is intentionally modular.
This package includes:
MessagevisorICU: ICU MessageFormat-style interpolation, plural, select, number, date, and time formatting.MessagevisorInterpolation: lightweight{name}replacement for primitive values.MessagevisorMissingTranslations: observes and optionally deduplicates missing translation diagnostics.
Most applications should choose either ICU or simple interpolation:
import Messagevisorimport MessagevisorICUlet m = createMessagevisor( MessagevisorOptions( datafile: datafile, modules: [createICUModule()] ))Without a formatting module, translate() returns the resolved message string unchanged.
Translations#
Translate a message by key:
try m.translate("auth.signin")// → "Sign in"Translating with values#
Pass values when the selected module needs them:
try m.translate( "dashboard.welcome", values: ["name": .string("Ada")])// → "Welcome back, Ada"MessagevisorValue supports strings, integers, doubles, booleans, dates, arrays, objects, and null:
let values: MessagevisorValues = [ "name": .string("Ada"), "count": .int(3), "price": .double(12.5), "enabled": .bool(true), "createdAt": .date(Date()),]t alias#
t() is an alias for translate():
try m.t("dashboard.welcome", values: ["name": .string("Ada")])Raw translation#
Use getRawTranslation() when you need the selected string before modules format or transform it:
let raw = try m.getRawTranslation("dashboard.welcome")Arbitrary messages#
Use formatMessage() to run a string through the same module pipeline without looking up a message key:
try m.formatMessage( "Hello, {name}", values: ["name": .string("Ada")])Context#
Context drives message override and segment conditions.
Initial context#
let m = createMessagevisor( MessagevisorOptions( datafile: datafile, context: [ "platform": .string("ios"), "plan": .string("pro"), ] ))Merge context#
setContext() shallow-merges by default:
m.setContext([ "userId": .string("user-123"),])Nested objects are replaced at their top-level key; they are not deep-merged.
Replace context#
m.setContext( ["userId": .string("user-456")], replace: true)Per-call context#
Pass request- or screen-specific context without mutating instance state:
try m.translate( "checkout.title", options: TranslateOptions( context: ["checkoutType": .string("express")] ))Per-call context shallow-merges over instance context for that evaluation only.
Datafile operations#
Messagevisor can hold datafiles for multiple locales and can combine split target datafiles for one locale.
Set after initialization#
m.setDatafile(datafile)JSON strings are accepted as well:
m.setDatafile(datafileJSON)Invalid JSON or an invalid datafile emits invalid_datafile with the stable message could not parse datafile, without throwing or changing stored state. JSON input requires schema version "1", string identity fields, a nonempty locale, and object maps for segments, messages, and translations. Optional formats must be an object and direction must be ltr or rtl. Typed datafiles also validate schema version, locale, and direction.
Merge by default#
When a datafile already exists for the incoming locale, setDatafile() merges segments, messages, and translations by key. Incoming identity fields win, while an omitted incoming direction preserves the existing direction. Formats merge by family and preset name: an incoming preset replaces the whole stored preset of the same name. Omitted formats, families, and presets remain available.
This supports loading several target datafiles for one locale on demand:
m.setDatafile(homeDatafile)m.setDatafile(checkoutDatafile)Replace explicitly#
m.setDatafile(freshDatafile, replace: true)Replacement removes entries that are absent from the incoming datafile.
Loading another locale#
The first successfully loaded datafile establishes the active locale when no locale was configured. Loading later locales does not silently switch it:
m.setDatafile(englishDatafile)m.setDatafile(dutchDatafile)try m.setLocale("nl-NL")Locales, currency, and time zones#
Active locale#
try m.setLocale("nl-NL")print(m.getLocale() ?? "")setLocale() requires a loaded datafile for that locale. A missing datafile emits missing_datafile and throws without changing state. A locale supplied to MessagevisorOptions can still be used with default translations and formats before a datafile arrives. When an initial datafile is supplied, its locale takes precedence over the constructor locale.
Per-call locale#
try m.translate( "checkout.title", options: TranslateOptions(locale: "fr-FR"))Per-call locale does not mutate instance state or emit locale events.
Direction#
let direction = try m.getDirection()// "ltr" or "rtl"getDirection() returns nil when no locale is active. Passing a locale explicitly still requires its datafile to be loaded.
Currency#
m.setCurrency("EUR")print(m.getCurrency() ?? "")Currency formats without an authored currency use the per-call currency, then the instance currency, then USD.
Time zone#
m.setTimeZone("Europe/Amsterdam")Per-call time zone and currency are available in both TranslateOptions and EvaluationOptions.
Time zone precedence is the call option, named preset, instance setting, then the host default captured once for the root and shared with children. This applies to direct date, time, and range helpers as well as ICU messages, including bare date and time arguments and date or time skeletons. A bare time includes hours, minutes, and seconds. Call options do not change instance state.
Formatting#
The ICU module supports common authored ICU messages:
try m.formatMessage( "{count, plural, =0 {No items} one {# item} other {# items}}", values: ["count": .int(2)])// → "2 items"It supports nested plural, selectordinal, and select, exact plural branches, offsets, apostrophe escaping, simple interpolation, and named number/date/time presets.
Missing arguments in the selected branch throw and emit invalid_message. Arguments inside quoted text or unselected branches are not evaluated. Exact plural branches compare the original value; category selection and # use the value after subtracting any offset. An empty selected branch remains empty.
The built in number styles percent and integer work without project presets. An explicitly named preset takes precedence over a built in style. Supported number skeleton tokens include currency/USD (or another currency code), percent, precision-integer, fixed fraction patterns such as .00##, group-off, group-auto, sign-always, sign-never, sign-except-zero, scientific, scale/100, and currency display widths unit-width-iso-code, unit-width-full-name, and unit-width-short.
try m.formatMessage("{amount, number, ::currency/USD .00}", values: ["amount": .double(0.5)])Unsupported number skeleton tokens emit unsupported_formatter and throw, followed by the usual invalid_message diagnostic. They never silently become plain decimal formatting. Number, date, and time presentation continues to use Foundation locale data.
Date and time presets can combine dateStyle with timeStyle, or date fields with time fields. Both direct helpers and ICU arguments preserve the complete preset. hour12 takes precedence over hourCycle when both are supplied.
An explicit dayPeriod uses Foundation's flexible day periods and the requested short, long, or narrow width. This can produce a phrase such as “in the morning” rather than AM or PM. Exact wording remains platform dependent.
Timezone-qualified ISO strings, numeric Unix epoch milliseconds, and native Date values can be used for date/time arguments:
try m.formatMessage( "{when, date, long}", values: ["when": .date(Date())])The Swift string API does not model JavaScript/React rich-message callbacks. With ignoreTags: true (the default), tags remain literal text. Requesting rich tag processing emits unsupported_formatter.
Direct formatter helpers#
The core SDK exposes Foundation-backed helpers:
try m.formatNumber(1234.5, preset: "money")try m.formatDate(Date(), preset: "long")try m.formatTime(Date(), preset: "short")try m.formatDateTimeRange(start, end, preset: "appointment")try m.formatRelativeTime(-1, unit: .day)try m.formatPlural(2)try m.formatList(["iOS", "macOS", "visionOS"])try m.formatDisplayName("NL", type: "region")The ToParts methods return a simplified portable representation because Foundation does not expose the same tokenized parts contract as JavaScript Intl:
let parts = try m.formatNumberToParts(1234.5)// [MessagevisorFormatPart(type: "literal", value: "1,234.5")]Every ToParts helper returns one literal part containing its native formatted result, including list punctuation and conjunctions, and emits unsupported_formatter. Missing named presets emit missing_format and use the native default format.
Supported display-name types are language, region, script, and currency.
Plural categories use the complete cardinal and ordinal rules generated from Unicode CLDR 48.2, including regional inheritance. Direct helpers and ICU messages share these rules. Negative numbers use their absolute value; nonfinite numbers select other without trapping.
Visible fraction and significant digits affect grammatical category selection:
try m.formatPlural(1, formatOptions: ["minimumFractionDigits": .int(2)], locale: "en")// → "other", because the plural operand is 1.00Fraction and significant digit limits are validated. Advanced plural rounding options that Foundation cannot map here emit unsupported_formatter; the default rounding mode is used. The pinned rules can be regenerated using scripts/generate-plural-rules.mjs, which updates the generated source, tests from Unicode's published samples, and Unicode licence directly. See UNICODE-LICENSE.txt for the data licence.
Format precedence#
Formats merge in this order:
defaultFormatssupplied to the SDK;- formats in the effective locale datafile;
- per-call
formats.
The merge extends to individual properties inside named presets.
Defaults#
Default translations#
Defaults allow startup copy or application-owned fallbacks before a datafile is available:
let m = createMessagevisor( MessagevisorOptions( locale: "en-US", defaultTranslations: [ "en-US": ["app.loading": "Loading…"] ] ))Read the configured defaults for the active locale or another locale:
let translations = m.getDefaultTranslations()let dutchTranslations = m.getDefaultTranslations(locale: "nl-NL")You can also pass a call-site fallback:
try m.translate( "optional.copy", options: TranslateOptions(defaultTranslation: "Fallback"))Empty strings are valid explicit translations and do not fall through.
Default formats#
let formats = FormatPresets( number: [ "money": [ "style": .string("currency"), "minimumFractionDigits": .int(2), ] ])let m = createMessagevisor( MessagevisorOptions( locale: "en-US", defaultFormats: ["en-US": formats] ))Default formats are shared with child instances and can be inspected without exposing mutable runtime storage:
let activeFormats = m.getDefaultFormats()let dutchFormats = child.getDefaultFormats(locale: "nl-NL")Feature and variation resolvers#
Message overrides can reference external feature flags or experiment variations. Messagevisor does not contact a feature service itself; provide resolvers:
let m = createMessagevisor( MessagevisorOptions( datafile: datafile, resolveFlag: { featureKey, context in featureKey == "newCheckout" && context["plan"] == .string("pro") }, resolveVariation: { experimentKey, _ in experimentKey == "checkoutCopy" ? "treatment" : nil } ))Resolvers can also be installed by modules. Removing that module restores the previous resolver registration.
Diagnostics#
Use onDiagnostic for observability:
let m = createMessagevisor( MessagevisorOptions( datafile: datafile, onDiagnostic: { diagnostic in logger.log("\(diagnostic.code): \(diagnostic.message)") }, logLevel: .warn ))Every diagnostic contains level, code, message, and an always-present details dictionary. Module provenance and an original Swift error can also be attached.
Stable diagnostic codes include:
sdk_initializedmissing_translationmissing_datafilemissing_localeinvalid_datafileinvalid_messageunsupported_formattermissing_formatinvalid_formatmessage_override_matcheddeprecated_messageduplicate_modulemodule_setup_errormodule_close_error
Without a handler, delivered diagnostics are written to standard error with a [Messagevisor] prefix. Change the threshold later with setLogLevel().
Error diagnostics emit the SDK error event even when the configured diagnostic threshold filters them from the handler.
Events and snapshots#
Subscribe to a specific event:
let unsubscribe = m.on(.localeSet) { event in if case .localeSet(let locale, let previousLocale) = event.details { print("\(previousLocale ?? "none") → \(locale)") }}unsubscribe()Available events are:
changeerrordatafile_setlocale_setcontext_setcurrency_settimeZone_set
subscribe() is a convenience for change notifications:
let unsubscribe = m.subscribe { render(m.getSnapshot())}Snapshots include a monotonically increasing version, active locale and direction, context, currency, time zone, loaded locales, and revisions by locale. A change event includes the specific source event and its details. Throwing event observers are isolated from state changes and other observers.
Modules#
A module can set up runtime integrations, format or transform translation output, observe/report diagnostics, and clean up resources:
let uppercase = MessagevisorModule( name: "uppercase", transform: { payload, _ in payload.translation.uppercased() })let m = createMessagevisor( MessagevisorOptions( datafile: datafile, modules: [uppercase] ))Return nil from format or transform to preserve the current value.
Constructor modules run setup after the initial datafile and defaults are ready, before sdk_initialized. Exceptions from either format or transform emit invalid_message with the module name and details containing the hook, effective locale, source, and message key, then rethrow the original error. Each error diagnostic emits one error event even when the diagnostic handler filters it out. Children own their evaluation diagnostics and error events.
Setup API#
setup receives a MessagevisorModuleApi with:
setFlagResolversetVariationResolvergetRevisiononDiagnosticreportDiagnostic
let observer = MessagevisorModule( name: "observer", setup: { api in _ = api.onDiagnostic({ diagnostic in print(diagnostic.code) }, MessagevisorModuleDiagnosticOptions(logLevel: .warn)) })Duplicate named modules are rejected. Anonymous modules are allowed. Setup failures roll back resolver and diagnostic registrations, report module_setup_error, and close partially initialized resources.
Add and remove at runtime#
let remove = m.addModule(uppercase)try await remove()The returned removal function is idempotent. You can also remove all modules with a name:
try await m.removeModule("uppercase")Module ownership belongs to the root instance, not child instances.
Child instances#
Use spawn() for request-, task-, or screen-scoped state:
let child: MessagevisorChild = m.spawn( context: ["requestId": .string("request-123")], options: SpawnOptions( locale: "nl-NL", currency: "EUR", timeZone: "Europe/Amsterdam" ))let title = try child.translate("checkout.title")try await child.close()Children share parent datafiles, modules, resolver registrations, and bounded formatter caches. They isolate context, locale, currency, time zone, snapshots, event versions, and cleanup. Parent datafile and module updates become visible dynamically. A child's datafile_set listeners and generic change listeners observe parent datafile updates through child-owned events containing the child's active locale, context, snapshots, and monotonically increasing version. Listener unsubscription is local and idempotent. Closing the child removes its single parent bridge without affecting the parent.
MessagevisorChild intentionally omits setDatafile, addModule, removeModule, and spawn, keeping shared resource ownership unambiguous.
Translation lookup#
For an effective locale and message key, the SDK resolves in this order:
- The first matching override whose conditions and segments both match.
- The base datafile translation.
defaultTranslations[locale][messageKey].- A
missing_datafileormissing_translationdiagnostic. - Per-call
defaultTranslation. - The message key.
Matching overrides emit message_override_matched at debug level. Deprecated messages emit deprecated_message. The full instance context is shallow-merged with per-call context before evaluating conditions and segments.
Condition operators are type-strict. Regular expressions use the portable i, m, s, and u flags, with u mapped to Foundation's Unicode-aware regular expression behavior. Repeated flags, lookarounds, named or non-capturing groups, inline flags, backreferences, and possessive quantifiers are not portable and do not match. Character classes and escaped literal backslashes remain valid. Invalid or non-portable expressions return false for both matches and notMatches.
Closing the SDK#
Close root instances when their lifecycle ends:
try await m.close()Closing clears event and diagnostic subscriptions and closes root-owned modules in reverse registration order. Cleanup continues after individual errors; failures emit module_close_error and are returned together as MessagevisorCloseError.
Repeated or concurrent calls to close() await the same cleanup operation. Modules are closed only once, and every caller receives the same aggregate failure when cleanup fails.
A module's close callback may start another close request, but must not await its own enclosing cleanup operation. Independent callers continue waiting for shared completion. Cleanup after a failed module setup is registered before observers receive module_setup_error, so an observer's close request also waits for that cleanup.
Removing a root module also removes its diagnostic subscriptions and cached APIs from live children. Retained APIs cannot register new subscriptions or resolvers after removal. Closing one child clears only that child's subscriptions and does not close the parent's modules.
Do not use an instance after closing it.
Apple platform behavior#
The SDK uses Foundation localization APIs and does not contain locale-specific output rewrites. Apple Foundation and JavaScript Intl can use different CLDR versions and formatter patterns, so punctuation, spacing, compact-number suffixes, localized zone names, and other presentation can differ while both outputs are valid.
Important platform notes:
- Formatter caches are bounded and shared with child instances.
- Public operations are synchronized for safe access from multiple threads. Public callback types are
@Sendable. Callback implementations must synchronize mutable captured state and should avoid long-running work on the caller's thread. - Foundation does not expose JavaScript-compatible tokenized formatter parts;
ToPartsmethods use a simplified representation. - Unsupported formatter capabilities use
unsupported_formatterwhere the SDK can identify them. Documented native fallbacks remain usable; unsupported ICU number skeleton tokens throw rather than discard their meaning. - Invalid explicit options, such as a malformed currency code or unknown time zone, emit
invalid_formatand throw aMessagevisorError. - Compact notation uses Foundation's locale data. Foundation does not expose separate short and long compact styles, so
compactDisplay: longemitsunsupported_formatterand uses the native compact form. - Rich ICU callback values are a JavaScript/framework feature; Swift translation results are strings.
Use expectedByRuntime.swift in shared project fixtures when an exact Apple-native expectation is intentional. Do not hardcode locale-specific rewrites into application or SDK code.
Project conformance CLI#
The package includes messagevisor-swift, a development runner that evaluates real Messagevisor project tests through the Swift SDK.
swift run messagevisor-swift test \ --projectDirectoryPath=/path/to/messagevisor-project \ --target=swift \ --withIcuModule \ --normalizeSpaces \ --onlyFailuresUseful commands:
swift run messagevisor-swift evaluate \ --projectDirectoryPath=/path/to/project \ --target=swift \ --locale=en-US \ --message=auth.signinswift run messagevisor-swift benchmark \ --projectDirectoryPath=/path/to/project \ --target=swift \ --locale=en-US \ --message=auth.signin \ -n 100000swift run messagevisor-swift examples \ --projectDirectoryPath=/path/to/project \ --target=swift \ --withIcuModule \ --normalizeSpacesThe runner shells out to the project's installed npx messagevisor CLI for source loading and datafile generation, then performs evaluations in Swift. Every assertion is compared; the runner never skips native formatting cases. --normalizeSpaces treats ordinary spaces, no-break spaces (U+00A0), and narrow no-break spaces (U+202F) as equivalent, matching the Java runner and keeping fixtures readable. All other Apple Foundation differences must be recorded explicitly with expectedByRuntime.swift.
Install only the modules enabled by the source project. The project 1 integration targets use ICU alone. Adding interpolation afterwards processes quoted placeholders a second time and changes their meaning. Swift dictionaries treat explicit names such as __proto__, constructor, and toString as ordinary keys in datafiles, defaults, context, and format presets.