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.
1. About this guide
Section titled “1. About this guide”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.
Departures from upstream guides
Section titled “Departures from upstream guides”- Two-letter acronyms are treated as words, like longer ones:
UiKitMapHost. In Kotlin conventions, two-letter acronyms are all capitals, as inIOStream[3], but one rule for all lengths is simpler, and Compose uses the same rule inUiComposable. - 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 generatedcomponentNfunctions still make field order part of the API.
2. Naming
Section titled “2. Naming”- Name classes with nouns and functions with verbs. Avoid filler words such as
ManagerandWrapper. [3] - Name composables that return
Unitwith 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
CompositionLocalnames withLocal. [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(), notPI. - Name a component’s defaults object after the component, with a
Defaultssuffix. [2] - Prefix a foundation component with
Basicwhen 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
.internalsubpackage of the package it supports. - Name packages after features. Don’t add catch-all packages such as
util.
// Doclass GeoJsonOptionsclass UiKitMapHostfun installMaplibreCompose()fun pi(): Expression<FloatValue>fun rememberMapState(): MapStateval LocalViewport = staticCompositionLocalOf<Viewport> { error("No viewport") }enum class QuickZoomDirection { UpZoomsIn, DownZoomsIn }val pitch: Double // style spec termobject LocationIndicatorDefaults@Composable fun BasicCompassButton() // overlay@Composable fun CompassButton() // material3// package org.maplibre.compose.layer// package org.maplibre.compose.layer.internal
// Don'tclass GeoJSONOptionsclass UIKitMapHostfun installMapLibreCompose()val PI: Expression<FloatValue>fun mapState(): MapStateval ViewportLocal = staticCompositionLocalOf<Viewport> { error("No viewport") }enum class QuickZoomDirection { UP_ZOOMS_IN, DOWN_ZOOMS_IN }val tilt: Doubleclass OfflineManager// package org.maplibre.compose.layers// package org.maplibre.compose.util3. General API design
Section titled “3. General API design”- 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
Booleanarguments whose meaning isn’t clear at the call site. NamedBooleanbuilder 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) orcheck(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
MapSnapshotExceptionorOfflineStorageException. These extendRuntimeException, have internal constructors, and keep the underlying exception as theircause. [4]
- 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
- Return
nullwhen 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
nullor an empty result instead of throwing. For example, a base-style layer handle expires when the engine reloads the style: its reads returnnull, 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
toStringthat 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]
// Docamera.animateTo(position)camera.snapTo(position)require(maximumFps == null || maximumFps > 0) { "maximumFps must be positive, was $maximumFps" }
// Don'tcamera.moveTo(position, true)require(maximumFps == null || maximumFps > 0)4. Types that may grow
Section titled “4. Types that may grow”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.
| Kind | Use when | Declare as | Example |
|---|---|---|---|
| Closed | The values can never change, because they describe math, a fixed protocol, or a complete split | enum class | QuickZoomDirection |
| Open input | Callers only pass values in, and the library won’t return them in any later minor release | enum class, or a sealed type when cases carry data | TileLodAlgorithm |
| Open identity | A raw value from outside the library, such as a style-spec or engine name, possibly one with no named constant in this version | value class with an internal constructor, a public value, and named values on the companion | LineCap |
| Open structure | The library returns it, including sets of names the library defines | sealed interface with an internal subtype, and a data object for each case without data | MapEvent |
- State the kind in KDoc: “Closed.” or “Values may be added in minor releases;
use an
elsebranch 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
nullinstead of a named catch-all such asUnknownorOther. Name a caseUnknownonly 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’
whenneeds anelsebranch. - 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@JvmInlinepublic 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 : MapEvent5. Options and configuration
Section titled “5. Options and configuration”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, ortoStringby 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
fromparameter in the builder instead of providingcopy, 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
fromandblockas its last parameters instead of the object, so that callers writecreateMapRuntime { … }. Keep the object as a parameter when the function takes several settings objects, such asMaplibreMap, already ends with another lambda, or is an interface member that other code implements, such asLocationProvider.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
expectclasses. The common declarations hold only what every platform shares, and eachactualadds its platform’s settings as members. An app whose targets all use MapLibre Native sees the nativeactual, including those members, in its own common code. Provide presets as companionvals named for their purpose, so that callers in common code can still select one. [4]
@Immutablepublic 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}6. Composables
Section titled “6. Composables”Parameters
Section titled “Parameters”- Order parameters: required, then
modifier: Modifier = Modifier, then optional, then an optional trailing lambda. [2] - Apply
modifieronce, 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
Modifiercan 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
XDefaultsobject, 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>orMutableState<T>. Take a value, or a() -> Tlambda for values that change often. [2] - For a value callers might want to set, such as a theme color, read the
CompositionLocalin 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
@Stableclass namedXState, created byrememberXState(). Departs from [1] by using a class instead of an interface. - Default the
stateparameter torememberXState(), never tonull. [1] - Mark public types
@Stableor@Immutablewhen they qualify, and never remove the annotation. [1]
Changing a signature
Section titled “Changing a signature”- To add a parameter, put it at the end (before any trailing lambda) with a
default value, and annotate it with
@IntroducedAt(section 12).
@Composablepublic 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)@Composablepublic fun MapButton( onClick: () -> Unit, modifier: Modifier = Modifier, colors: MapButtonColors = MapButtonDefaults.colors(), @IntroducedAt("1.2") enabled: Boolean = true, content: @Composable () -> Unit,)7. Callbacks
Section titled “7. Callbacks”- 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,
thisrefers 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 aswithPlatformMap { }, 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 withonDispose. Calling it again replaces the handler. - Make a callback that may wait on I/O a
suspendfunction. - 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]
// Dopublic fun interface MissingImageResolver { public suspend fun resolve(request: MissingImageRequest): ResolvedStyleImage?}
interactions { onClick { event -> select(event.hits.first().feature) ClickResult.Consume }}
// Don'tpublic typealias MissingImageResolver = suspend (id: String) -> ResolvedStyleImage?
interactions { onClick { // `this` is the event select(hits.first().feature) true }}8. Types the library creates
Section titled “8. Types the library creates”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
componentNfollows field order. Departs from [4]. - Make engine-backed types, such as
MapRuntimeandMapSnapshotter, 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 MapSnapshotter9. Platform-specific APIs
Section titled “9. Platform-specific APIs”- 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, orWeb. 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 throwUnsupportedOperationExceptionor 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.mappublic class AndroidMapPresentationpublic class WebMapPresentation
// Don't: package org.maplibre.compose.browserpublic class BrowserMapPresentation10. Units and value types
Section titled “10. Units and value types”- 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:
Floatwhere Compose or the engine usesFloat, such as a pixel ratio, andDoubleotherwise. [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.
| Quantity | Type |
|---|---|
| Duration | kotlin.time.Duration |
| Geographic position and bounds | spatial-k Position and BoundingBox |
| Real-world distance | spatial-k Length |
| Direction of travel or heading | spatial-k Bearing |
| Screen distance | Compose Dp |
| Insets passed in composition | Compose PaddingValues, resolved with LocalLayoutDirection |
| Insets in stored data or image pixels | DpPadding |
| Screen density | Compose Density |
| Color | Compose Color |
val animationDuration: Duration // Doval animationDurationMillis: Long // Don't, when Duration fitsval tileZoom: Int // discreteval zoom: Double // continuousval uptimeMillis: Long // plain number, unit in the name11. Documentation
Section titled “11. Documentation”- 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
@seeor 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 */12. Stability and compatibility
Section titled “12. Stability and compatibility”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 valorinlinefunction. Their values and bodies are copied into callers’ compiled code. [4] - Don’t remove targets from an annotation’s
@Target. Don’t addVALUE_PARAMETER,PROPERTY, orFIELDto 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
@IntroducedAtand 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’scopy. [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
@SubclassOptInRequiredinstead of for every use.
Stability annotations
Section titled “Stability annotations”| Annotation | Meaning |
|---|---|
ExperimentalMaplibreComposeApi | May 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. |
DelicateMaplibreComposeApi | Stable, but easy to misuse. KDoc explains how. |
| None | Stable. |
Deprecation cycle
Section titled “Deprecation cycle”- Required: in a minor release, mark it
@DeprecatedatWARNING, withReplaceWithwhen there is a replacement. [4] - Optional: in a later minor release, raise it to
ERROR, then toHIDDEN, which keeps the declaration in the binary. Departs from [4], which requires all three. - In the next major release, delete it.
Sources
Section titled “Sources”- API Guidelines for Jetpack Compose: naming, state hoisting, and stability for composables.
- API Guidelines for @Composable components in Jetpack Compose: parameters, defaults, layering, documentation, and evolution of components.
- Kotlin coding conventions: naming, and the extra conventions for libraries.
- 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.