Designing Type‑Safe Map SDK Adapters: From Google Maps to Waze‑Style Features
sdktypesmapping

Designing Type‑Safe Map SDK Adapters: From Google Maps to Waze‑Style Features

ttypescript
2026-01-26 12:00:00
11 min read
Advertisement

Design TypeScript adapter layers so you can swap Google Maps and Mapbox without breaking types, using generics, mapped types, and runtime feature negotiation.

Swap mapping providers without breaking your types — the pain point, solved

You shipped a feature that relies on Google Maps SDK. Then budget or policy changes push you to Mapbox — and suddenly your codebase is full of SDK-specific types and runtime checks. You either rewrite large swaths of code, or accept brittle runtime branching. Both slow teams down and increase bugs.

This article shows a pragmatic, TypeScript-first approach to build typed adapter layers for mapping SDKs that let you swap providers (Google Maps, Mapbox, or others) without breaking application types. Expect concrete patterns using generics, conditional types, and mapped types, plus runtime feature negotiation so your UI and business logic safely enable — or gracefully degrade — features like Waze-style incident reporting and live rerouting.

The constraints and design goals

Before code, define what you need the adapter to guarantee. Keep this checklist in mind as you design your types and runtime surface.

  • Provider-agnostic app code: Business logic should reference only our adapter types, not raw SDK types.
  • Compile-time safety: If the app expects an adapter that supports routing, the type system should enforce it.
  • Feature negotiation: The runtime adapter exposes which features are present; UI can adapt accordingly.
  • Minimal runtime overhead: Lightweight wrappers around SDK calls — not reimplementations.
  • Extensible: Adding a new provider or feature should be easy and type-safe.

Core idea — typed capabilities + conditional APIs

Model each provider as a small descriptor of capabilities (e.g., baseMap, routing, liveTraffic, incidentReporting). Then parameterize your adapter by that capability set so TypeScript can present different method surfaces depending on compile-time capabilities.

Feature definitions (TypeScript)

// Feature names we care about
export type Feature =
  | 'baseMap'
  | 'routing'
  | 'liveTraffic'
  | 'incidentReporting'
  | 'offlineTiles';

// Map a feature to the runtime boolean that indicates support
export type FeatureSet = Partial>;

We use a small Feature union and a FeatureSet shape that providers will declare with as const so the compiler keeps those literal keys and values.

Constructing the adapter API with conditional types

The magic: create an adapter type that exposes methods only when the Capability Set includes the feature. Use conditional types and mapped types to do this.

type IfHas<Caps extends FeatureSet, F extends Feature, T> =
  F extends keyof Caps ? T : never;

// Core typed adapter. We parametrize by Caps so methods appear conditionally.
export type MapAdapter<Caps extends FeatureSet = {}> = {
  // Always present basic map hooks
  init(container: HTMLElement, options?: unknown): Promise<void>;
  destroy(): void;

  // Show a coordinate or bounding box (base map required)
  setViewport: IfHas<Caps, 'baseMap', (bounds: { north: number; south: number; east: number; west: number }) => void>;

  // Routing API only if provider supports it
  getRoute: IfHas<Caps, 'routing', (from: [number, number], to: [number, number]) => Promise<Route>>;

  // Live traffic may be a subscription
  subscribeTraffic: IfHas<Caps, 'liveTraffic', (onUpdate: (data: TrafficUpdate) => void) => () => void>;

  // Incident reporting (Waze-style) is optional
  reportIncident: IfHas<Caps, 'incidentReporting', (incident: Incident) => Promise<ReportResult>>;

  // A runtime capability list you can inspect
  capabilities: Readonly<Caps>;
};

The IfHas helper yields either the function signature or never. When a consumer receives an adapter typed with a particular capability set, they can only call the methods supported by that set.

Provider descriptors — how each SDK declares capabilities

Each concrete adapter exports a literal capability descriptor. The compiler uses that to infer the adapter type in factories. Example: Google Maps and Mapbox.

// googleAdapter.ts
export const GoogleCaps = {
  baseMap: true,
  routing: true, // Directions API
  liveTraffic: true,
  // Google doesn't provide direct crowd-sourced incident reporting in SDK
} as const;

// mapboxAdapter.ts
export const MapboxCaps = {
  baseMap: true,
  routing: true, // Mapbox Directions
  liveTraffic: false, // depends on plan / plugin
  incidentReporting: true, // using Mapbox Events or custom
  offlineTiles: true,
} as const;

Use as const to preserve literal types so TypeScript knows exactly which keys exist.

Implementing adapters — typed wrappers around SDKs

Adapters should do two things: (1) map provider SDK calls to the adapter API and (2) expose the capability descriptor as the adapter's readonly capabilities. Below is a simplified pattern for a Google adapter and Mapbox adapter.

import type { MapAdapter } from './types';

// A tiny route type for demos
export type Route = { distanceMeters: number; geometry: any };
export type TrafficUpdate = { speedFactor: number };
export type Incident = { location: [number, number]; type: string; comment?: string };
export type ReportResult = { id: string };

// Runtime factory that returns a typed adapter instance
export function createGoogleAdapter(): MapAdapter<typeof GoogleCaps> {
  // internal google map SDK wiring omitted for brevity
  const adapter = {
    capabilities: GoogleCaps,
    async init(container: HTMLElement) {
      // mount the Google Map — real code references window.google.maps
    },
    destroy() {},
    setViewport(bounds: { north: number; south: number; east: number; west: number }) {
      // call googleMap.fitBounds(...)
    },
    async getRoute(from: [number, number], to: [number, number]) {
      // call DirectionsService and normalize result into Route
      return { distanceMeters: 1234, geometry: null };
    },
    subscribeTraffic(onUpdate: (data: TrafficUpdate) => void) {
      // subscribe to traffic feed, return unsubscribe
      const unsub = () => {};
      return unsub;
    },
  } as const;

  return adapter as unknown as MapAdapter<typeof GoogleCaps>;
}

Note how the returned value is asserted to the appropriate generic. With proper implementations, this cast isn't unsafe because the object matches the declared method shapes.

Feature negotiation in the app — safe, ergonomic patterns

At the call site you'll often have an adapter with an unknown capability set, but need to call a feature conditionally. There are two complementary strategies:

  1. Compile-time guarantees via generics: If a component requires routing, accept an adapter typed with routing capability (e.g., MapAdapter<{ routing: true }>). Consumers must pass an adapter that includes routing, otherwise TypeScript errors at compile time.
  2. Runtime negotiation with type guards: For UI elements that can optionally use a feature, check adapter.capabilities at runtime and use a small generic type guard to narrow the adapter so the compiler knows the method exists.

Type guard helper

function hasFeature<F extends Feature, Caps extends FeatureSet>(
  adapter: MapAdapter<Caps>,
  feature: F,
): adapter is MapAdapter<Caps & Record<F, true>> {
  return !!(adapter.capabilities as Record<string, unknown>)[feature];
}

Usage example in a UI component:

function IncidentButton({ adapter }: { adapter: MapAdapter }) {
  if (!hasFeature(adapter, 'incidentReporting')) {
    return <button disabled title="Incidents not supported">Report Incident</button>;
  }

  // Now TypeScript knows adapter.reportIncident exists
  async function onReport() {
    const result = await adapter.reportIncident({ location: [0, 0], type: 'accident' });
    console.log('reported', result.id);
  }

  return <button onClick={onReport}>Report Incident</button>;
}

Advanced types — mapping provider-specific options and responses

Providers often return provider-specific shapes. Use mapped types and conditional mapping to expose a unified app-level type while still allowing provider-specific richness when needed.

// Provider-specific payloads
type GoogleRoutePayload = { polyline: string; durationSec: number };
type MapboxRoutePayload = { geometry: { coordinates: number[][] }; durationMs: number };

// Map provider id to payload type
type ProviderName = 'google' | 'mapbox';

type RoutePayloadFor<P extends ProviderName> = P extends 'google'
  ? GoogleRoutePayload
  : P extends 'mapbox'
  ? MapboxRoutePayload
  : never;

// Expose both normalized and raw payloads
type NormalizedRoute = { distanceMeters: number; raw: unknown };

export type ProviderAdapter<P extends ProviderName, Caps extends FeatureSet = {}> =
  MapAdapter<Caps> & { provider: P; getRawRoute?: (from: [number, number], to: [number, number]) => Promise<RoutePayloadFor<P>> };

This pattern lets advanced consumers call getRawRoute when they need provider-specific data, while normal app flows use the normalized shape.

Runtime fallback & graceful degradation strategies

If a provider lacks a feature you need, plan for fallbacks. Options include:

  • Server-side polyfills: Route resolution or crowdsourced incident storage can be implemented on the server and exposed via your own API.
  • Feature toggle and UX hiding: If live traffic is unavailable, hide related controls and provide a clear message — avoid a broken UI.
  • Hybrid providers: Use multiple providers for specific responsibilities (Mapbox for tiles, external routing service for navigation) and expose a composed adapter with aggregated capabilities.

For hybrid composition, ensure the composed adapter's capability map is the union of the constituent capabilities and that calling a method routes to the provider that implements it.

Testing adapters and migration strategy

Unit test your adapters with small fakes rather than loading full SDKs. For migration from Google Maps to Mapbox, follow a staged approach:

  1. Extract all direct SDK calls into adapters (one commit) so the rest of the app depends only on adapter interfaces.
  2. Implement the Mapbox adapter to satisfy the same interfaces, using capability flags to mark any gaps.
  3. Deploy a dual-run or feature-flagged release where some users hit Mapbox and others Google. Use monitoring to validate parity.
  4. When parity is achieved, flip the global flag and remove the retired adapter in a cleanup change.

Use lightweight test doubles that implement MapAdapter<...> so components can be tested deterministically. Example test double:

export function makeFakeAdapter<Caps extends FeatureSet = {}>(caps: Caps): MapAdapter<Caps> {
  return {
    capabilities: caps as Readonly<Caps>,
    async init() {},
    destroy() {},
    setViewport: ((_ ) => {}) as any,
    getRoute: (async () => ({ distanceMeters: 0, geometry: null })) as any,
    subscribeTraffic: (() => () => {}) as any,
    reportIncident: (async () => ({ id: 'fake' })) as any,
  } as any;
}

By 2026, mapping SDKs have continued to fragment and specialize. A few trends influence architecture:

  • Modular SDKs and minimal bundles: Providers shipping smaller, opt-in modules (late-2025) make it easier to implement adapters that only import required features.
  • Privacy-first and offline features: Demand for offline tiles and local routing increased — adapters let you swap to privacy-focused providers per region.
  • ML-assisted routing: Providers are exposing ML-based ETA adjustments and predicted traffic; adapters help you choose when to use those experimental features.
  • Provider pricing volatility: With shifting pricing models, teams need the option to swap without weeks of refactoring.

These realities make typed adapter layers a strategic investment: they reduce coupling, accelerate provider changes, and ensure your team can reason about which features are available where — at compile time, not just at runtime.

"Design your app's capabilities first, then map providers to those capabilities." — Practical rule of thumb for resilient mapping platforms

Actionable checklist — from idea to production

  1. Inventory features you need (routing, traffic, incident reporting, offline tiles).
  2. Design a Feature union and capability map as shown above.
  3. Create a strict adapter type parameterized by capability set using conditional types.
  4. Implement adapters for each provider with a literal capability descriptor (as const).
  5. Use generic constraints on components that require specific features; use runtime type guards for optional features.
  6. Implement fallbacks or hybrid providers for missing capabilities.
  7. Test with small fakes and stage migration with feature flags.

Example — a route-focused component that requires routing

function RoutePlanner<Caps extends FeatureSet>({ adapter }: { adapter: MapAdapter<Caps> & unknown }) {
  // Require at compile time that adapter supports routing
  type RequiresRouting = MapAdapter<{ routing: true } & Caps>;

  const typed = adapter as RequiresRouting; // enforce at call site or via props type

  async function plan() {
    const route = await typed.getRoute([0, 0], [1, 1]);
    console.log('distance', route.distanceMeters);
  }

  return <button onClick={plan}>Plan Route</button>;
}

If a consumer passes an adapter that's missing routing, TypeScript flags the mismatch. This lets your CI catch configuration errors early.

Wrap-up: practical takeaways

  • Model capabilities explicitly: A small Feature union unlocks a lot of type-safety and clarity.
  • Use generics + conditional types: Build adapters whose method surfaces depend on compile-time capability sets.
  • Combine compile-time enforcement and runtime negotiation: Require features for critical components and negotiate for optional UI elements.
  • Plan for fallbacks: Runtime composition and server-side polyfills mitigate provider gaps.
  • Test with fakes: Adapter fakes help you decouple tests from heavyweight SDKs and network calls.

Call to action

Ready to modernize your mapping stack? Clone a starter repository that implements the patterns in this article, or drop in your provider's SDK and create a typed adapter in under an hour. If you want, paste your current SDK usage here and I'll sketch a typed adapter scaffold you can use to begin the migration.

Advertisement

Related Topics

#sdk#types#mapping
t

typescript

Contributor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

Advertisement
2026-01-24T03:57:08.138Z