One Host App, Many Markets: Build a TypeScript Policy Resolver
Multi-market mobile apps often begin with a shared codebase and end with country branches hidden inside conditionals.
if (country === "SG") {
// one payment flow
} else if (country === "GB") {
// another consent screen
}
This works until market, product, regulation, client version, and service availability begin changing independently. The host app becomes the place where every local decision is implemented and every market must join the same release train.
A modular platform needs a different boundary. The host should expose stable capabilities. A versioned market policy should describe which local implementations and services are available. Mini apps consume the resolved policy without embedding country-specific assumptions.

In this tutorial, we will build a TypeScript policy resolver that supports:
- global capability contracts;
- market-specific locales, currencies, and time zones;
- approved identity and payment providers;
- service availability and minimum host versions;
- consent-policy versions;
- capability restrictions by market;
- explicit validation and deterministic precedence.
The code is deliberately small enough to understand. A production implementation would add signed policy bundles, remote configuration delivery, audit history, staged rollout, and operational tooling.
Model stable capabilities first
The global platform should define the capabilities services can request. Their implementations may vary by market.
Create market-policy.ts:
export type Capability =
| "identity.profile"
| "identity.verify"
| "payments.create"
| "payments.refund"
| "analytics.emit"
| "location.coarse"
| "storage.secure";
export type ProviderKind = "identity" | "payment";
export interface Provider {
id: string;
kind: ProviderKind;
enabled: boolean;
capabilities: Capability[];
}
export interface ServicePolicy {
serviceId: string;
enabled: boolean;
minimumHostVersion: string;
requiredCapabilities: Capability[];
allowedAudienceSegments: string[];
}
export interface MarketPolicy {
market: string;
revision: number;
locales: string[];
defaultLocale: string;
currencies: string[];
timeZone: string;
consentPolicyVersion: string;
providers: Provider[];
deniedCapabilities: Capability[];
services: ServicePolicy[];
effectiveFrom: string;
}
The market code should be an agreed identifier, ideally an ISO country or operating-market code. Locale identifiers should use a consistent standard such as BCP 47. Do not use one free-form language field for locale, market, and regulatory jurisdiction; they are related but different.
deniedCapabilities gives the market a way to narrow the global surface. A capability can exist in the host and still be unavailable in a jurisdiction because the required provider, permission, or approval is absent.
Keep defaults conservative
A global default should provide structural expectations, not silently enable market-sensitive functionality.
export interface GlobalPolicy {
supportedCapabilities: Capability[];
requiredServiceCapabilities: Capability[];
minimumConsentPolicyVersion: string;
}
export const globalPolicy: GlobalPolicy = {
supportedCapabilities: [
"identity.profile",
"identity.verify",
"payments.create",
"payments.refund",
"analytics.emit",
"location.coarse",
"storage.secure",
],
requiredServiceCapabilities: ["analytics.emit"],
minimumConsentPolicyVersion: "3.0.0",
};
Notice that the global object does not name a default payment provider or currency. Choosing one would create unsafe fallback behaviour. If a market policy is missing its provider, payment-dependent services should remain unavailable.
Validate the policy before resolving it
Remote configuration should be treated as executable product policy. Validate it before the host or a mini app uses it.
const MARKET_CODE = /^[A-Z]{2}$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
function duplicateValues(values: string[]): string[] {
return values.filter((value, index) => values.indexOf(value) !== index);
}
export function validateMarketPolicy(
global: GlobalPolicy,
market: MarketPolicy,
now: Date
): string[] {
const errors: string[] = [];
if (!MARKET_CODE.test(market.market)) {
errors.push("market must be a two-letter upper-case code");
}
if (!market.locales.includes(market.defaultLocale)) {
errors.push("defaultLocale must appear in locales");
}
if (!SEMVER.test(market.consentPolicyVersion)) {
errors.push("consentPolicyVersion must use x.y.z semantic versioning");
} else if (
compareSemver(
market.consentPolicyVersion,
global.minimumConsentPolicyVersion
) < 0
) {
errors.push("consentPolicyVersion is below the global minimum");
}
if (duplicateValues(market.locales).length > 0) {
errors.push("locales contains duplicates");
}
if (duplicateValues(market.currencies).length > 0) {
errors.push("currencies contains duplicates");
}
if (duplicateValues(market.deniedCapabilities).length > 0) {
errors.push("deniedCapabilities contains duplicates");
}
const supported = new Set(global.supportedCapabilities);
for (const denied of market.deniedCapabilities) {
if (!supported.has(denied)) {
errors.push(`market denies unknown capability: ${denied}`);
}
}
const providerIds = market.providers.map((provider) => provider.id);
if (duplicateValues(providerIds).length > 0) {
errors.push("provider ids must be unique inside a market");
}
for (const provider of market.providers) {
for (const capability of provider.capabilities) {
if (!supported.has(capability)) {
errors.push(`${provider.id} exposes unknown ${capability}`);
}
}
}
const serviceIds = market.services.map((service) => service.serviceId);
if (duplicateValues(serviceIds).length > 0) {
errors.push("service ids must be unique inside a market");
}
for (const service of market.services) {
if (!SEMVER.test(service.minimumHostVersion)) {
errors.push(`${service.serviceId} has an invalid minimumHostVersion`);
}
for (const capability of service.requiredCapabilities) {
if (!supported.has(capability)) {
errors.push(`${service.serviceId} requests unknown ${capability}`);
}
}
}
const effective = new Date(market.effectiveFrom);
if (Number.isNaN(effective.getTime())) {
errors.push("effectiveFrom must be a valid timestamp");
} else if (effective > now) {
errors.push("policy is not effective yet");
}
return errors;
}
Schema validation libraries are useful when policy arrives as JSON. The explicit function above keeps cross-field rules visible: the default locale must be allowed, IDs must be unique, and every capability must belong to the global contract.
Production validation should also check that locales and time zones exist, currencies use valid codes, policy age is acceptable, consent versions meet the global minimum, and the policy signature chains to an approved issuer.
Resolve an effective market view
Mini apps should not repeat policy logic. The host can produce an immutable resolved view.
export interface EffectiveMarket {
market: string;
revision: number;
locales: readonly string[];
defaultLocale: string;
currencies: readonly string[];
timeZone: string;
consentPolicyVersion: string;
availableCapabilities: ReadonlySet<Capability>;
providers: readonly Provider[];
services: readonly ServicePolicy[];
}
export function resolveMarket(
global: GlobalPolicy,
market: MarketPolicy,
now: Date
): EffectiveMarket {
const errors = validateMarketPolicy(global, market, now);
if (errors.length > 0) {
throw new Error(`Invalid market policy: ${errors.join("; ")}`);
}
const denied = new Set(market.deniedCapabilities);
const availableCapabilities = new Set(
global.supportedCapabilities.filter((capability) => !denied.has(capability))
);
const providers = market.providers
.filter((provider) => provider.enabled)
.filter((provider) =>
provider.capabilities.every((capability) =>
availableCapabilities.has(capability)
)
);
const services = market.services
.filter((service) => service.enabled)
.filter((service) =>
[...global.requiredServiceCapabilities, ...service.requiredCapabilities]
.every((capability) => availableCapabilities.has(capability))
);
return Object.freeze({
market: market.market,
revision: market.revision,
locales: Object.freeze([...market.locales]),
defaultLocale: market.defaultLocale,
currencies: Object.freeze([...market.currencies]),
timeZone: market.timeZone,
consentPolicyVersion: market.consentPolicyVersion,
availableCapabilities,
providers: Object.freeze(providers.map((provider) => Object.freeze({ ...provider }))),
services: Object.freeze(services.map((service) => Object.freeze({ ...service }))),
});
}
The filtering rules fail closed. If a capability is denied, providers and services that depend on it disappear from the effective view. They do not receive a partially working configuration.
ReadonlySet communicates intent but does not make JavaScript sets immutable at runtime. Do not expose the returned set to untrusted code. A production bridge can serialise it to a frozen array or answer capability checks through a host-controlled method.

Resolve service eligibility for a specific client
Market availability alone is insufficient. The customer also needs a compatible host version and audience membership.
function compareSemver(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] - b[index];
}
return 0;
}
export interface ClientContext {
hostVersion: string;
audienceSegments: string[];
}
export function eligibleServices(
effective: EffectiveMarket,
client: ClientContext
): ServicePolicy[] {
return effective.services.filter((service) => {
if (compareSemver(client.hostVersion, service.minimumHostVersion) < 0) {
return false;
}
return service.allowedAudienceSegments.some((segment) =>
client.audienceSegments.includes(segment)
);
});
}
Audience segments should come from a trusted host service. A mini app should never assign itself to a privileged or regulated segment. Eligibility is also separate from consent: being allowed to see a service does not automatically grant that service access to profile or payment data.
Define two markets without forking code
Now define policies for Singapore and the United Kingdom:
export const sgPolicy: MarketPolicy = {
market: "SG",
revision: 7,
locales: ["en-SG", "zh-SG"],
defaultLocale: "en-SG",
currencies: ["SGD"],
timeZone: "Asia/Singapore",
consentPolicyVersion: "3.2.0",
providers: [
{
id: "sg-identity-provider",
kind: "identity",
enabled: true,
capabilities: ["identity.verify"],
},
{
id: "sg-payment-rail",
kind: "payment",
enabled: true,
capabilities: ["payments.create", "payments.refund"],
},
],
deniedCapabilities: [],
services: [
{
serviceId: "merchant-rewards",
enabled: true,
minimumHostVersion: "8.3.0",
requiredCapabilities: ["identity.profile", "analytics.emit"],
allowedAudienceSegments: ["consumer"],
},
],
effectiveFrom: "2026-08-01T00:00:00Z",
};
export const gbPolicy: MarketPolicy = {
market: "GB",
revision: 4,
locales: ["en-GB"],
defaultLocale: "en-GB",
currencies: ["GBP"],
timeZone: "Europe/London",
consentPolicyVersion: "3.1.0",
providers: [
{
id: "gb-payment-provider",
kind: "payment",
enabled: true,
capabilities: ["payments.create", "payments.refund"],
},
],
deniedCapabilities: ["location.coarse"],
services: [
{
serviceId: "merchant-rewards",
enabled: true,
minimumHostVersion: "8.3.0",
requiredCapabilities: ["identity.profile", "analytics.emit"],
allowedAudienceSegments: ["consumer"],
},
{
serviceId: "local-events",
enabled: true,
minimumHostVersion: "8.4.0",
requiredCapabilities: ["location.coarse", "analytics.emit"],
allowedAudienceSegments: ["consumer"],
},
],
effectiveFrom: "2026-08-10T00:00:00Z",
};
Both markets use the same service definition for merchant rewards. Providers, locale, currency, time zone, consent revision, and capability availability vary through policy.
The United Kingdom policy denies coarse location. The resolver will remove local-events from the effective service list because it requires that capability. The host does not need a country condition inside the service loader.
Test the market boundary
Use Vitest to keep the intended behaviour stable:
import { describe, expect, it } from "vitest";
import {
eligibleServices,
gbPolicy,
globalPolicy,
resolveMarket,
sgPolicy,
type MarketPolicy,
} from "./market-policy";
const now = new Date("2026-08-26T12:00:00Z");
describe("market policy resolver", () => {
it("removes services that require a denied capability", () => {
const effective = resolveMarket(globalPolicy, gbPolicy, now);
expect(effective.services.map((service) => service.serviceId))
.toEqual(["merchant-rewards"]);
});
it("keeps market-specific providers behind global contracts", () => {
const effective = resolveMarket(globalPolicy, sgPolicy, now);
expect(effective.providers.map((provider) => provider.id)).toEqual([
"sg-identity-provider",
"sg-payment-rail",
]);
});
it("blocks services on an old host version", () => {
const effective = resolveMarket(globalPolicy, sgPolicy, now);
const visible = eligibleServices(effective, {
hostVersion: "8.2.9",
audienceSegments: ["consumer"],
});
expect(visible).toHaveLength(0);
});
it("rejects an unrecognised market code", () => {
const invalid: MarketPolicy = { ...sgPolicy, market: "SINGAPORE" };
expect(() => resolveMarket(globalPolicy, invalid, now))
.toThrow("market must be a two-letter upper-case code");
});
});
The tests demonstrate the separation of responsibilities. Global code knows capability names and resolution rules. Market policies choose providers and narrow availability. Services do not contain country branches.
Deliver policy like code
A policy bundle can change customer eligibility and access to sensitive capabilities. Treat it with the same care as executable code.
A production delivery path should include:
- immutable revision numbers;
- schema and semantic validation;
- a digital signature from an approved policy publisher;
- staged rollout and market-specific approval;
- a last-known-good policy stored by the host;
- expiry and forced-refresh rules;
- an audit event for every activation and rollback;
- a global kill switch for a service or provider.
Do not merge arbitrary JSON objects recursively. Deep merge makes precedence difficult to inspect and can preserve a field that a market intended to remove. Resolve explicit structures and use allowlists for capabilities, providers, and services.
The host should also define its behaviour when the policy cannot be verified. Sensitive services should fail closed. Low-risk static content may use a cached last-known-good revision. The fallback needs to be designed before an outage.
Observe global reuse and local results
The policy resolver should emit operational events containing the market, revision, host version, service ID, decision, and reason. Useful reasons include:
service_disabledhost_version_too_oldcapability_deniedprovider_unavailableaudience_not_eligiblepolicy_expiredpolicy_signature_invalid
At platform level, these events show how often local variation is handled by configuration and where markets still request host changes. At product level, teams should also measure task completion, payment success, errors, and support demand in each market.
Technical reuse and local customer value are separate outcomes. A service can load through the same contract everywhere and still perform poorly in one market because its proposition, language, provider, or journey is wrong.
One host can carry deliberate differences
A multi-market platform becomes easier to operate when it is explicit about three categories:
- global capabilities and safety controls;
- versioned market policy and provider choices;
- locally owned services and customer outcomes.
The TypeScript resolver built here keeps those categories visible. It filters denied capabilities, incompatible providers, unavailable services, old host versions, and ineligible audiences without creating country branches in the native shell.
That is the architectural objective: a global improvement can spread through the common host, while a necessary local change travels through policy or a modular service. One app remains recognisable across markets without requiring every market to behave the same way.













