Skip to content

API guidelines

Rules for the public API of the published libraries. This guide is intended for v1.0.0 and later; the current API doesn’t fully conform to it yet.

This guide covers every public or @PublishedApi declaration in the published libraries. It doesn’t cover internal code, the demo app, or the benchmarks.

The API is designed for Kotlin callers using Compose. Calling it from Java or any other JVM language except Kotlin isn’t supported, so a declaration that is awkward or impossible to call from Java is fine. Examples: value classes in signatures, which mangle JVM method names, and missing @JvmOverloads, @JvmStatic, or @JvmName.

Each section lists rules, then examples. A rule taken from an upstream guide ends with a bracketed number, such as [1], that links to the relevant section; the guides are also listed under Sources at the end. If a rule doesn’t fit a case, change the rule in the same PR instead of making an exception in code.

Rules enforced deterministically, such as by a linter, should be removed from this guide.

  • Two-letter acronyms are treated as words, like longer ones: UiKitMapHost. In Kotlin conventions, two-letter acronyms are all capitals, as in IOStream [3], but one rule for all lengths is simpler, and Compose uses the same rule in UiComposable.
  • Constants and enum entries use PascalCase, as in Compose [1]. Kotlin conventions specify screaming snake case for constants [3].
  • State holders are final classes, not the interfaces Compose recommends [1]. Map state is backed by the engine, so a caller’s implementations couldn’t work, and each new member would break every implementation.
  • Data classes are allowed, contrary to the Kotlin library guidelines [4], when their fields are fixed by definition, when their constructor is internal, or when new fields are added last with @IntroducedAt (section 12). Their generated componentN functions still make field order part of the API.
  • Name classes with nouns and functions with verbs. Avoid filler words such as Manager and Wrapper. [3]
  • Name composables that return Unit with PascalCase nouns. Name composables that return a value like other functions. [1]
  • Start the name of a composable that remembers and returns a mutable object with remember. [1]
  • Treat acronyms and initialisms as words in code, including two-letter ones and ones with digits: Maplibre, GeoJson, D3d11. In prose and KDoc, use the usual spelling: MapLibre, GeoJSON, UI. Departs from [3].
  • Use PascalCase for constants, singleton objects, and enum entries. [1] Departs from [3].
  • Start CompositionLocal names with Local. [1]
  • When the MapLibre style spec has a term for something, use it, in camel case.
  • In the expression DSL, write operators that take no arguments as functions, not constants: pi(), not PI.
  • Name a component’s defaults object after the component, with a Defaults suffix. [2]
  • Prefix a foundation component with Basic when the Material 3 module has a component with the same name. [2]
  • End receiver scope interface names with Scope.
  • Name opt-in annotations <Adjective>MaplibreComposeApi.
  • Use lowercase, singular package names. [3]
  • Put internal code in an .internal subpackage of the package it supports.
  • Name packages after features. Don’t add catch-all packages such as util.
// Do
class GeoJsonOptions
class UiKitMapHost
fun installMaplibreCompose()
fun pi(): Expression<FloatValue>
fun rememberMapState(): MapState
val LocalViewport = staticCompositionLocalOf<Viewport> { error("No viewport") }
enum class QuickZoomDirection { UpZoomsIn, DownZoomsIn }
val pitch: Double // style spec term
object LocationIndicatorDefaults
@Composable fun BasicCompassButton() // overlay
@Composable fun CompassButton() // material3
// package org.maplibre.compose.layer
// package org.maplibre.compose.layer.internal
// Don't
class GeoJSONOptions
class UIKitMapHost
fun installMapLibreCompose()
val PI: Expression<FloatValue>
fun mapState(): MapState
val ViewportLocal = staticCompositionLocalOf<Viewport> { error("No viewport") }
enum class QuickZoomDirection { UP_ZOOMS_IN, DOWN_ZOOMS_IN }
val tilt: Double
class OfflineManager
// package org.maplibre.compose.layers
// package org.maplibre.compose.util
  • Build on a small set of core operations, and add conveniences that combine them. [4]
  • Keep a type’s members to its core concept. Put extras in extension functions and properties. [4]
  • Prefer small pieces that callers combine over more parameters. [4]
  • Use the same parameter names and order across related functions, from general to specific. [4]
  • Make overloads of a function behave the same way. [4]
  • Avoid Boolean arguments whose meaning isn’t clear at the call site. Named Boolean builder properties are fine. [4]
  • Choose the exception by whether correct code could have avoided it:
    • A programming error is a call that correct code never makes, such as an invalid argument or using an object after closing it. Reject it with require (IllegalArgumentException) or check (IllegalStateException), with a message that describes the problem and includes the rejected value.
    • A failure that correct code can’t prevent, such as the engine failing to render a snapshot or the offline database failing, throws the library exception for that feature, such as MapSnapshotException or OfflineStorageException. These extend RuntimeException, have internal constructors, and keep the underlying exception as their cause. [4]
  • Return null when data can’t be found or computed. Don’t use exceptions for control flow. [4]
  • When state can change outside the caller’s control, and the caller can’t check it first without a race, return null or an empty result instead of throwing. For example, a base-style layer handle expires when the engine reloads the style: its reads return null, and its writes do nothing and log a warning. Using an object after the caller closed it is a programming error, and throws.
  • Make behavior unsurprising instead of documenting a surprise. When behavior is wrong, fix it instead of documenting it.
  • Give stateful types a toString that shows their contents in a consistent format, without secrets such as access tokens. [4]
  • Don’t keep state in global variables or stateful top-level functions. Let callers pass the object in. [4]
// Do
camera.animateTo(position)
camera.snapTo(position)
require(maximumFps == null || maximumFps > 0) { "maximumFps must be positive, was $maximumFps" }
// Don't
camera.moveTo(position, true)
require(maximumFps == null || maximumFps > 0)

Every enum, sealed type, and set of named values is one of four kinds. Choose the kind by what any later minor release might expose, because changing the shape later is a breaking change.

KindUse whenDeclare asExample
ClosedThe values can never change, because they describe math, a fixed protocol, or a complete splitenum classQuickZoomDirection
Open inputCallers only pass values in, and the library won’t return them in any later minor releaseenum class, or a sealed type when cases carry dataTileLodAlgorithm
Open identityA raw value from outside the library, such as a style-spec or engine name, possibly one with no named constant in this versionvalue class with an internal constructor, a public value, and named values on the companionLineCap
Open structureThe library returns it, including sets of names the library definessealed interface with an internal subtype, and a data object for each case without dataMapEvent
  • State the kind in KDoc: “Closed.” or “Values may be added in minor releases; use an else branch when matching.”
  • Closed promises that no value is added before the next major release. Choose it only when you can’t name a value a later release might add; when in doubt, choose an open kind.
  • Use sealed types, internal subtypes, and internal constructors so that callers can’t create unexpected values. [4]
  • Keep unrecognized values in an open identity. Don’t map them to a named value such as Unknown.
  • When a value means “no answer”, such as an unclassified failure or a setting the platform doesn’t report, use null instead of a named catch-all such as Unknown or Other. Name a case Unknown only when the engine or platform reports a real value with that meaning.
  • Use a value class only to wrap a raw value from outside the library. When the library defines the names itself, use an open structure instead of inventing a raw value.
  • Give an open structure an internal subtype, so that callers’ when needs an else branch.
  • Group named values on a companion object or a sealed interface, not a plain object. When a value belongs to several sealed types, nest it in one and implement the others: sealed interface CameraAction { data object Pan : CameraAction, DragAction }.
// Open identity: can hold values with no named constant in this version
@JvmInline
public value class LineCap internal constructor(override val value: String) : EnumValue {
public companion object : EnumType<LineCap> {
public val Butt: LineCap = LineCap("butt")
public val Round: LineCap = LineCap("round")
public val Square: LineCap = LineCap("square")
override val entries: List<LineCap> = listOf(Butt, Round, Square)
}
}
// Open structure: callers can't reference the internal subtype, so their `when` needs `else`
public sealed interface MapEvent {
public class CameraMoved internal constructor(public val reason: CameraMoveReason) : MapEvent
}
internal object UnspecifiedMapEvent : MapEvent

Settings objects, such as options, constraints, and requests, always use the shape shown below, even when every setting works on every platform today. A setting that only some platforms support can then be added later without changing the shape.

  • Use a data class with a private or internal constructor and a builder, so new options don’t change a public constructor. Write equals, hashCode, or toString by hand only when a data class cannot express the behavior.
  • A value that describes something rather than configuring behavior, such as a camera position or the four edges of DpPadding, can be a data class with a public constructor. If it might gain fields, add them last with @IntroducedAt (section 12).
  • Take a from parameter in the builder instead of providing copy, and default it to a standard preset on the companion object.
  • Take required values with no sensible default, such as an ID, as constructor parameters before block.
  • When a function or constructor takes one settings object as its only settings, take that object’s from and block as its last parameters instead of the object, so that callers write createMapRuntime { … }. Keep the object as a parameter when the function takes several settings objects, such as MaplibreMap, already ends with another lambda, or is an interface member that other code implements, such as LocationProvider.updates.
  • Within a major version, don’t start rejecting a value that an earlier release accepted.
  • Make every option that all platforms support settable from common code, with defaults that need no platform setup. [4]
  • When settings differ by source set, such as JS and native, declare the options class and its builder as expect classes. The common declarations hold only what every platform shares, and each actual adds its platform’s settings as members. An app whose targets all use MapLibre Native sees the native actual, including those members, in its own common code. Provide presets as companion vals named for their purpose, so that callers in common code can still select one. [4]
@Immutable
public data class RenderOptions private constructor(
public val maximumFps: Int?,
public val tileLod: TileLodOptions,
) {
private constructor(builder: Builder) : this(builder.maximumFps, builder.tileLod)
public constructor(from: RenderOptions = Standard, block: Builder.() -> Unit) :
this(Builder(from).apply(block))
@MapOptionsDsl
public class Builder internal constructor(from: RenderOptions?) {
public var maximumFps: Int? = from?.maximumFps
public var tileLod: TileLodOptions = from?.tileLod ?: TileLodOptions.Standard
}
public companion object {
public val Standard: RenderOptions = RenderOptions(Builder(from = null))
}
}
val options = RenderOptions(from = RenderOptions.Standard) {
maximumFps = 30
tileLod = TileLodOptions.Performance
}
  • Order parameters: required, then modifier: Modifier = Modifier, then optional, then an optional trailing lambda. [2]
  • Apply modifier once, as the first modifier on the root layout. Add your own modifiers after it, never before. [1]
  • Don’t add a parameter for something a Modifier can do, unless it’s core to the component. [2]
  • Emit content or return a value, never both. [1]
  • Write each default value inline or on the XDefaults object, and never call internal code from it. [2]
  • Make a parameter nullable only when absence means something, never to mean “use the default”. [2]
  • Don’t take State<T> or MutableState<T>. Take a value, or a () -> T lambda for values that change often. [2]
  • For a value callers might want to set, such as a theme color, read the CompositionLocal in a parameter’s default value, not in the implementation. [2]
  • Group related settings into one options parameter (section 5) that defaults to a named preset.
  • Hoist caller-controlled state into a @Stable class named XState, created by rememberXState(). Departs from [1] by using a class instead of an interface.
  • Default the state parameter to rememberXState(), never to null. [1]
  • Mark public types @Stable or @Immutable when they qualify, and never remove the annotation. [1]
  • To add a parameter, put it at the end (before any trailing lambda) with a default value, and annotate it with @IntroducedAt (section 12).
@Composable
public fun MapButton(
onClick: () -> Unit, // required
modifier: Modifier = Modifier, // first optional
colors: MapButtonColors = MapButtonDefaults.colors(), // public default
content: @Composable () -> Unit, // trailing lambda
)
LineLayer(id = "routes", source = routes) {
color = const(Color.Blue)
interactions {
onClick { event -> ClickResult.Consume }
}
}
// Adding `enabled` in 1.2
@OptIn(ExperimentalVersionOverloading::class)
@Composable
public fun MapButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
colors: MapButtonColors = MapButtonDefaults.colors(),
@IntroducedAt("1.2") enabled: Boolean = true,
content: @Composable () -> Unit,
)
  • When a callback receives information from the library, such as an event or a request, pass it as one parameter of a library type with an internal constructor, so that the type can gain fields.
  • Don’t give callbacks a receiver. Inside a callback with a receiver, this refers to the receiver instead of the enclosing class. This covers callbacks that the library calls later. DSL builder blocks and blocks that run before the function returns, such as withPlatformMap { }, may use a receiver.
  • Name the callback parameters of composables onX.
  • Declare every named callback type as a fun interface, not a typealias, so that they’re consistent and each has its own KDoc.
  • Name a builder function that sets a handler onX, as Compose does with onDispose. Calling it again replaces the handler.
  • Make a callback that may wait on I/O a suspend function.
  • Document which thread each callback runs on, whether it can run in parallel or reentrantly, and what happens if it throws. [4]
  • Handle an exception from a callback by what the callback does:
    • A callback that does I/O, such as a resource or tile provider, can fail. Its exception means that one request, tile, or image failed, and the library reports it the way it reports that kind of failure.
    • In any other callback, such as a predicate or a builder block, an exception is a bug. Let it fail the operation that ran the callback, reported the same way as that operation’s other failures, with no special path.
    • A logger’s exceptions are dropped, because logging must never break the app.
  • Make an operation that produces a final result, such as a snapshot, fail as a whole when part of it fails, instead of returning an incomplete result.
  • Keep interfaces that callers implement small, and give any new member a default implementation. [4]
// Do
public fun interface MissingImageResolver {
public suspend fun resolve(request: MissingImageRequest): ResolvedStyleImage?
}
interactions {
onClick { event ->
select(event.hits.first().feature)
ClickResult.Consume
}
}
// Don't
public typealias MissingImageResolver = suspend (id: String) -> ResolvedStyleImage?
interactions {
onClick { // `this` is the event
select(hits.first().feature)
true
}
}

Events, requests, hits, stats, and summaries are created by the library and only read by callers.

  • Make constructors internal.
  • Data classes are fine. Add new fields only after existing ones, because componentN follows field order. Departs from [4].
  • Make engine-backed types, such as MapRuntime and MapSnapshotter, final classes or sealed interfaces, like state holders. [4]
  • Make scopes, such as MapOverlayScope, sealed. [4]
  • Accept and return read-only collections, and copy collections that are mutable underneath. [4]
public data class RenderStats internal constructor(
public val frameCount: Long,
public val drawCallCount: Long,
public val droppedFrameCount: Long, // added later, so it goes last
)
public sealed interface MapSnapshotter
  • Put as much of the API as possible in common code. [4]
  • Make common APIs behave the same on every platform where practical, and document any difference, such as in how invalid input is handled. [4]
  • Put a platform-specific declaration in its feature’s package, with a platform prefix.
  • Use one prefix per platform: Android, Apple (iOS and macOS), Ios, Macos, Desktop (JVM), Linux, Windows, or Web. A backend or toolkit name follows the platform prefix: DesktopMetalGpuContext.
  • Declarations in a source set shared by several platforms, such as the MapLibre Native one, take no prefix.
  • Leave a feature out of the source set of a platform that doesn’t support it. When support varies within a source set at runtime, such as by operating system or desktop environment on the JVM, and callers need to know before acting, such as to decide whether to show a button, expose a capability property such as canOpenLocationServicesSettings. Otherwise, when a feature must stay in common code, reads return an empty result, and writes throw UnsupportedOperationException or do nothing, as documented.
  • Don’t name public packages after platforms or engine bindings.
  • For extra platform arguments, add an overload of a common factory function.
  • In artifacts that only provide a runtime, put public declarations in the package of the feature they implement.
// Do: package org.maplibre.compose.map
public class AndroidMapPresentation
public class WebMapPresentation
// Don't: package org.maplibre.compose.browser
public class BrowserMapPresentation
  • Use a typed value when one exists for the quantity (see the table). [4]
  • For plain numbers, choose the type by how the value is used: an integer for discrete values, and a floating-point type for continuous ones. Use the type that the value is processed as, so values round-trip unchanged: Float where Compose or the engine uses Float, such as a pixel ratio, and Double otherwise. [4]
  • Don’t use number types for identifiers. [4]
  • When a plain number’s type doesn’t show its unit, end its name with the unit.
  • The table lists common quantities. For others, apply the rules above.
QuantityType
Durationkotlin.time.Duration
Geographic position and boundsspatial-k Position and BoundingBox
Real-world distancespatial-k Length
Direction of travel or headingspatial-k Bearing
Screen distanceCompose Dp
Insets passed in compositionCompose PaddingValues, resolved with LocalLayoutDirection
Insets in stored data or image pixelsDpPadding
Screen densityCompose Density
ColorCompose Color
val animationDuration: Duration // Do
val animationDurationMillis: Long // Don't, when Duration fits
val tileZoom: Int // discrete
val zoom: Double // continuous
val uptimeMillis: Long // plain number, unit in the name
  • Write KDoc for every public declaration, except overrides that add nothing. [3]
  • Write KDoc as the contract: behavior, parameters, return values, lifecycle, threading, and platform limits. Don’t describe how it’s implemented.
  • Start with one sentence that says what the declaration does, without restating the signature. [4]
  • State valid input ranges, what happens with invalid input, and every exception. [4]
  • Add a sample only when usage isn’t clear from the signature and KDoc.
  • Link related declarations with @see or KDoc links. [4]
  • Document behavior once, in common code. Document platform differences that can’t be avoided. [4]
  • Write simple English, without idioms, Latin abbreviations, or jargon. [4]
  • Keep contracts out of the docs site. Site pages introduce concepts and common tasks, and link to the API reference for details.
/**
* Shows a button that turns the map back to north.
*
* Tapping it animates the camera bearing to 0.
*
* @param modifier The [Modifier] to apply to the button.
* @param size The button's width and height. Must not be negative.
* @throws IllegalArgumentException if [size] is negative.
* @see ScaleBar
*/

Within a major version, stable APIs (see Stability annotations) keep binary, source, and behavior compatibility: [4]

  • Code compiled against an earlier release keeps linking and running.
  • Source code keeps compiling, except for deprecated declarations.
  • Documented behavior and serialized formats don’t change.

These guarantees don’t cover a when with no else branch over a type that may grow (section 4), or callers in JVM languages other than Kotlin (section 1).

To keep these guarantees:

  • Don’t widen or narrow a return type. [4]
  • Use caution when adding a public const val or inline function. Their values and bodies are copied into callers’ compiled code. [4]
  • Don’t remove targets from an annotation’s @Target. Don’t add VALUE_PARAMETER, PROPERTY, or FIELD to an annotation that already allows one of the others, because existing uses on constructor properties could move to a different element. [4]
  • To add a parameter to a public function or constructor, give it a default value, annotate it with @IntroducedAt and the release version, and put it after the existing parameters, before any trailing lambda. Versions must increase from left to right. The compiler generates hidden overloads for callers compiled against earlier releases, including overloads of a data class’s copy. [4]
  • Keep the serialized names of fields. When renaming a property, keep reading its old name with @JsonNames.
  • Prefer sealed types over interfaces that callers implement. When callers must implement an interface and implementing it is easy to get wrong, require opt-in for implementations with @SubclassOptInRequired instead of for every use.
AnnotationMeaning
ExperimentalMaplibreComposeApiMay change in any minor release. For new APIs without much real use, and APIs that expose a dependency we don’t expect to stabilize before this library does, such as Skiko or the engine bindings.
DelicateMaplibreComposeApiStable, but easy to misuse. KDoc explains how.
NoneStable.
  1. Required: in a minor release, mark it @Deprecated at WARNING, with ReplaceWith when there is a replacement. [4]
  2. Optional: in a later minor release, raise it to ERROR, then to HIDDEN, which keeps the declaration in the binary. Departs from [4], which requires all three.
  3. In the next major release, delete it.
  1. API Guidelines for Jetpack Compose: naming, state hoisting, and stability for composables.
  2. API Guidelines for @Composable components in Jetpack Compose: parameters, defaults, layering, documentation, and evolution of components.
  3. Kotlin coding conventions: naming, and the extra conventions for libraries.
  4. Kotlin library authors’ guidelines: backward compatibility, predictability, documentation, and multiplatform design.

Each rule taken from one of these guides ends with its number, linked to the relevant section. Rules without a citation are this project’s own.