2017-10-21 16:44:55 +00:00
|
|
|
# kotlin-result
|
|
|
|
|
2022-04-15 15:44:34 +00:00
|
|
|
[![Maven Central](https://img.shields.io/maven-central/v/com.michael-bull.kotlin-result/kotlin-result.svg)](https://search.maven.org/search?q=g:com.michael-bull.kotlin-result)
|
2024-03-03 06:16:38 +00:00
|
|
|
[![CI](https://github.com/michaelbull/kotlin-result/actions/workflows/ci.yaml/badge.svg)](https://github.com/michaelbull/kotlin-result/actions/workflows/ci.yaml)
|
2022-04-15 15:44:34 +00:00
|
|
|
[![License](https://img.shields.io/github/license/michaelbull/kotlin-result.svg)](https://github.com/michaelbull/kotlin-result/blob/master/LICENSE)
|
|
|
|
|
2023-01-05 23:48:20 +00:00
|
|
|
![badge][badge-android]
|
2022-04-15 15:44:34 +00:00
|
|
|
![badge][badge-jvm]
|
|
|
|
![badge][badge-js]
|
|
|
|
![badge][badge-nodejs]
|
|
|
|
![badge][badge-linux]
|
|
|
|
![badge][badge-windows]
|
2023-01-05 23:48:20 +00:00
|
|
|
![badge][badge-wasm]
|
|
|
|
![badge][badge-ios]
|
2022-04-15 15:44:34 +00:00
|
|
|
![badge][badge-mac]
|
2023-01-05 23:48:20 +00:00
|
|
|
![badge][badge-tvos]
|
|
|
|
![badge][badge-watchos]
|
2022-04-15 15:44:34 +00:00
|
|
|
![badge][badge-js-ir]
|
2023-01-05 23:48:20 +00:00
|
|
|
![badge][badge-android-native]
|
2022-04-15 15:44:34 +00:00
|
|
|
![badge][badge-apple-silicon]
|
2017-10-21 23:59:16 +00:00
|
|
|
|
2024-03-02 17:10:53 +00:00
|
|
|
A multiplatform Result monad for modelling success or failure operations.
|
2017-10-21 16:44:55 +00:00
|
|
|
|
2018-01-11 20:25:59 +00:00
|
|
|
## Installation
|
|
|
|
|
|
|
|
```groovy
|
|
|
|
repositories {
|
2020-02-12 14:32:53 +00:00
|
|
|
mavenCentral()
|
2018-01-11 20:25:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
dependencies {
|
2024-03-16 20:37:00 +00:00
|
|
|
implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.21")
|
2018-01-11 20:25:59 +00:00
|
|
|
}
|
|
|
|
```
|
|
|
|
|
|
|
|
## Introduction
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
In functional programming, the result [`Result`][result] type is a monadic type
|
|
|
|
holding a returned [value][result-value] or an [error][result-error].
|
2018-01-11 20:25:59 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
To indicate an operation that succeeded, return an [`Ok(value)`][result-Ok]
|
|
|
|
with the successful `value`. If it failed, return an [`Err(error)`][result-Err]
|
|
|
|
with the `error` that caused the failure.
|
2018-01-11 20:25:59 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
This helps to define a clear happy/unhappy path of execution that is commonly
|
|
|
|
referred to as [Railway Oriented Programming][rop], whereby the happy and
|
|
|
|
unhappy paths are represented as separate railways.
|
2018-01-11 20:25:59 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
### Overhead
|
|
|
|
|
|
|
|
The `Result` type is modelled as an
|
|
|
|
[inline value class][kotlin-inline-classes]. This achieves zero object
|
|
|
|
allocations on the happy path.
|
|
|
|
|
|
|
|
A full breakdown, with example output Java code, is available in the
|
|
|
|
[Overhead][wiki-Overhead] design doc.
|
|
|
|
|
|
|
|
### Multiplatform Support
|
|
|
|
|
|
|
|
`kotlin-result` targets all three tiers outlined by the
|
|
|
|
[Kotlin/Native target support][kotlin-native-target-support]
|
|
|
|
|
|
|
|
### Read More
|
2021-01-30 22:26:15 +00:00
|
|
|
|
|
|
|
Below is a collection of videos & articles authored on the subject of this
|
|
|
|
library. Feel free to open a pull request on [GitHub][github] if you would like
|
|
|
|
to include yours.
|
|
|
|
|
|
|
|
- [[EN] The Result Monad - Adam Bennett](https://adambennett.dev/2020/05/the-result-monad/)
|
2021-01-30 22:38:09 +00:00
|
|
|
- [[EN] A Functional Approach to Exception Handling - Tristan Hamilton](https://youtu.be/bEC_t8dH23c?t=132)
|
2021-01-30 22:26:15 +00:00
|
|
|
- [[EN] kotlin: A functional gold mine - Mark Bucciarelli](http://markbucciarelli.com/posts/2020-01-04_kotlin_functional_gold_mine.html)
|
|
|
|
- [[EN] Railway Oriented Programming - Scott Wlaschin](https://fsharpforfunandprofit.com/rop/)
|
|
|
|
- [[JP] KotlinでResult型使うならkotlin-resultを使おう](https://note.com/yasukotelin/n/n6d9e352c344c)
|
|
|
|
- [[JP] kotlinのコードにReturn Resultを組み込む](https://nnao45.hatenadiary.com/entry/2019/11/30/224820)
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
Mappings are available on the [wiki][wiki] to assist those with experience
|
|
|
|
using the `Result` type in other languages:
|
|
|
|
|
|
|
|
- [Elm](https://github.com/michaelbull/kotlin-result/wiki/Elm)
|
|
|
|
- [Haskell](https://github.com/michaelbull/kotlin-result/wiki/Haskell)
|
|
|
|
- [Rust](https://github.com/michaelbull/kotlin-result/wiki/Rust)
|
|
|
|
- [Scala](https://github.com/michaelbull/kotlin-result/wiki/Scala)
|
|
|
|
|
2021-01-30 22:31:32 +00:00
|
|
|
## Getting Started
|
2018-01-11 20:25:59 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
Below is a simple example of how you may use the `Result` type to model a
|
|
|
|
function that may fail.
|
2018-01-11 20:25:59 +00:00
|
|
|
|
|
|
|
```kotlin
|
2018-01-24 18:20:39 +00:00
|
|
|
fun checkPrivileges(user: User, command: Command): Result<Command, CommandError> {
|
|
|
|
return if (user.rank >= command.mininimumRank) {
|
|
|
|
Ok(command)
|
|
|
|
} else {
|
|
|
|
Err(CommandError.InsufficientRank(command.name))
|
|
|
|
}
|
2018-01-11 20:25:59 +00:00
|
|
|
}
|
|
|
|
```
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
When interacting with code outside your control that may throw exceptions, wrap
|
|
|
|
the call with [`runCatching`][result-runCatching] to capture its execution as a
|
|
|
|
`Result<T, Throwable>`:
|
2019-08-23 23:31:23 +00:00
|
|
|
|
|
|
|
```kotlin
|
2019-12-20 14:51:24 +00:00
|
|
|
val result: Result<Customer, Throwable> = runCatching {
|
|
|
|
customerDb.findById(id = 50) // could throw SQLException or similar
|
2019-08-23 23:31:23 +00:00
|
|
|
}
|
|
|
|
```
|
|
|
|
|
2019-12-20 14:51:24 +00:00
|
|
|
Nullable types, such as the `find` method in the example below, can be
|
2018-01-24 18:20:39 +00:00
|
|
|
converted to a `Result` using the `toResultOr` extension function.
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
val result: Result<Customer, String> = customers
|
|
|
|
.find { it.id == id } // returns Customer?
|
|
|
|
.toResultOr { "No customer found" }
|
|
|
|
```
|
|
|
|
|
2018-01-11 20:25:59 +00:00
|
|
|
### Transforming Results
|
|
|
|
|
|
|
|
Both success and failure results can be transformed within a stage of the
|
|
|
|
railway track. The example below demonstrates how to transform an internal
|
2024-03-16 20:11:19 +00:00
|
|
|
program error `UnlockError` into the exposed client error `IncorrectPassword`.
|
2018-01-11 20:25:59 +00:00
|
|
|
|
|
|
|
```kotlin
|
2019-12-20 14:51:24 +00:00
|
|
|
val result: Result<Treasure, UnlockResponse> =
|
2018-01-11 20:25:59 +00:00
|
|
|
unlockVault("my-password") // returns Result<Treasure, UnlockError>
|
2018-01-24 18:20:39 +00:00
|
|
|
.mapError { IncorrectPassword } // transform UnlockError into IncorrectPassword
|
2018-01-11 20:25:59 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
### Chaining
|
|
|
|
|
|
|
|
Results can be chained to produce a "happy path" of execution. For example, the
|
2019-12-20 14:51:24 +00:00
|
|
|
happy path for a user entering commands into an administrative console would
|
2018-01-11 20:25:59 +00:00
|
|
|
consist of: the command being tokenized, the command being registered, the user
|
2018-01-24 18:20:39 +00:00
|
|
|
having sufficient privileges, and the command executing the associated action.
|
|
|
|
The example below uses the `checkPrivileges` function we defined earlier.
|
2018-01-11 20:25:59 +00:00
|
|
|
|
|
|
|
```kotlin
|
|
|
|
tokenize(command.toLowerCase())
|
2018-01-24 12:58:26 +00:00
|
|
|
.andThen(::findCommand)
|
2018-01-11 20:25:59 +00:00
|
|
|
.andThen { cmd -> checkPrivileges(loggedInUser, cmd) }
|
|
|
|
.andThen { execute(user = loggedInUser, command = cmd, timestamp = LocalDateTime.now()) }
|
|
|
|
.mapBoth(
|
|
|
|
{ output -> printToConsole("returned: $output") },
|
|
|
|
{ error -> printToConsole("failed to execute, reason: ${error.reason}") }
|
|
|
|
)
|
|
|
|
```
|
|
|
|
|
2020-05-11 12:04:50 +00:00
|
|
|
### Binding (Monad Comprehension)
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
The [`binding`][result-binding] function allows multiple calls that each return
|
|
|
|
a `Result` to be chained imperatively. When inside a `binding` block, the
|
|
|
|
`bind()` function is accessible on any `Result`. Each call to `bind` will
|
|
|
|
attempt to unwrap the `Result` and store its value, returning early if any
|
|
|
|
`Result` is an error.
|
2020-05-11 12:04:50 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
In the example below, should `functionX()` return an error, then execution will
|
|
|
|
skip both `functionY()` and `functionZ()`, instead storing the error from
|
2020-05-11 12:04:50 +00:00
|
|
|
`functionX` in the variable named `sum`.
|
|
|
|
|
|
|
|
```kotlin
|
2024-03-16 20:11:19 +00:00
|
|
|
fun functionX(): Result<Int, SumError> { ... }
|
|
|
|
fun functionY(): Result<Int, SumError> { ... }
|
|
|
|
fun functionZ(): Result<Int, SumError> { ... }
|
2020-05-11 12:04:50 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
val sum: Result<Int, SumError> = binding {
|
2020-05-11 12:04:50 +00:00
|
|
|
val x = functionX().bind()
|
|
|
|
val y = functionY().bind()
|
|
|
|
val z = functionZ().bind()
|
|
|
|
x + y + z
|
|
|
|
}
|
|
|
|
|
|
|
|
println("The sum is $sum") // prints "The sum is Ok(100)"
|
|
|
|
```
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
The `binding` function primarily draws inspiration from
|
2020-05-11 12:04:50 +00:00
|
|
|
[Bow's `binding` function][bow-bindings], however below is a list of other
|
|
|
|
resources on the topic of monad comprehensions.
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
- [Monad comprehensions - Arrow (Kotlin)](https://arrow-kt.io/docs/0.10/patterns/monad_comprehensions/)
|
|
|
|
- [Monad comprehensions - Bow (Swift)](https://bow-swift.io/docs/patterns/monad-comprehensions)
|
|
|
|
- [For comprehensions - Scala](https://docs.scala-lang.org/tour/for-comprehensions.html)
|
2020-05-11 12:04:50 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
#### Coroutine Binding Support
|
2020-08-07 13:51:27 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
Use of suspending functions within a `coroutineBinding` block requires an
|
|
|
|
additional dependency:
|
2020-08-07 13:51:27 +00:00
|
|
|
|
|
|
|
```kotlin
|
|
|
|
dependencies {
|
2024-03-16 20:37:00 +00:00
|
|
|
implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.21")
|
|
|
|
implementation("com.michael-bull.kotlin-result:kotlin-result-coroutines:1.1.21")
|
2020-08-07 13:51:27 +00:00
|
|
|
}
|
|
|
|
```
|
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
The [`coroutineBinding`][result-coroutineBinding] function runs inside a
|
|
|
|
[`coroutineScope`][kotlin-coroutineScope], facilitating _concurrent
|
|
|
|
decomposition of work_.
|
|
|
|
|
|
|
|
When any call to `bind()` inside the block fails, the scope fails, cancelling
|
|
|
|
all other children.
|
2021-02-10 22:34:44 +00:00
|
|
|
|
|
|
|
The example below demonstrates a computationally expensive function that takes
|
|
|
|
five milliseconds to compute being eagerly cancelled as soon as a smaller
|
|
|
|
function fails in just one millisecond:
|
2020-08-07 13:51:27 +00:00
|
|
|
|
2021-02-10 22:34:44 +00:00
|
|
|
```kotlin
|
2020-08-07 13:51:27 +00:00
|
|
|
suspend fun failsIn5ms(): Result<Int, DomainErrorA> { ... }
|
|
|
|
suspend fun failsIn1ms(): Result<Int, DomainErrorB> { ... }
|
|
|
|
|
2021-02-10 22:34:44 +00:00
|
|
|
runBlocking {
|
2024-03-16 20:11:19 +00:00
|
|
|
val result: Result<Int, BindingError> = coroutineBinding { // this creates a new CoroutineScope
|
2020-08-07 13:51:27 +00:00
|
|
|
val x = async { failsIn5ms().bind() }
|
|
|
|
val y = async { failsIn1ms().bind() }
|
|
|
|
x.await() + y.await()
|
|
|
|
}
|
|
|
|
|
|
|
|
// result will be Err(DomainErrorB)
|
|
|
|
}
|
|
|
|
```
|
|
|
|
|
2017-10-21 16:44:55 +00:00
|
|
|
## Inspiration
|
|
|
|
|
|
|
|
Inspiration for this library has been drawn from other languages in which the
|
|
|
|
Result monad is present, including:
|
|
|
|
|
|
|
|
- [Elm](http://package.elm-lang.org/packages/elm-lang/core/latest/Result)
|
|
|
|
- [Haskell](https://hackage.haskell.org/package/base-4.10.0.0/docs/Data-Either.html)
|
|
|
|
- [Rust](https://doc.rust-lang.org/std/result/)
|
2018-01-11 20:25:59 +00:00
|
|
|
- [Scala](http://www.scala-lang.org/api/2.12.4/scala/util/Either.html)
|
2017-10-21 16:44:55 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
Improvements on existing solutions such the stdlib include:
|
2017-10-21 16:44:55 +00:00
|
|
|
|
2024-03-16 20:11:19 +00:00
|
|
|
- Reduced runtime overhead with zero object allocations on the happy path
|
2017-10-21 21:33:59 +00:00
|
|
|
- Feature parity with Result types from other languages including Elm, Haskell,
|
|
|
|
& Rust
|
2017-10-21 18:44:58 +00:00
|
|
|
- Lax constraints on `value`/`error` nullability
|
2017-10-21 18:46:14 +00:00
|
|
|
- Lax constraints on the `error` type's inheritance (does not inherit from
|
2017-10-21 18:46:44 +00:00
|
|
|
`Exception`)
|
2024-03-16 20:11:19 +00:00
|
|
|
- Top level `Ok` and `Err` functions avoids qualifying usages with
|
2017-10-22 15:10:05 +00:00
|
|
|
`Result.Ok`/`Result.Err` respectively
|
2017-10-21 16:44:55 +00:00
|
|
|
- Higher-order functions marked with the `inline` keyword for reduced runtime
|
|
|
|
overhead
|
|
|
|
- Extension functions on `Iterable` & `List` for folding, combining, partitioning
|
|
|
|
- Consistent naming with existing Result libraries from other languages (e.g.
|
|
|
|
`map`, `mapError`, `mapBoth`, `mapEither`, `and`, `andThen`, `or`, `orElse`,
|
|
|
|
`unwrap`)
|
2020-09-26 15:53:07 +00:00
|
|
|
- Extensive test suite with almost 100 [unit tests][unit-tests] covering every library method
|
2017-10-21 18:44:58 +00:00
|
|
|
|
2018-01-24 13:21:01 +00:00
|
|
|
## Example
|
|
|
|
|
|
|
|
The [example][example] module contains an implementation of Scott's
|
|
|
|
[example application][swalschin-example] that demonstrates the usage of `Result`
|
|
|
|
in a real world scenario.
|
|
|
|
|
|
|
|
It hosts a [ktor][ktor] server on port 9000 with a `/customers` endpoint. The
|
|
|
|
endpoint responds to both `GET` and `POST` requests with a provided `id`, e.g.
|
2019-12-20 14:51:24 +00:00
|
|
|
`/customers/100`. Upserting a customer id of 42 is hardcoded to throw an
|
2024-03-16 20:11:19 +00:00
|
|
|
[`SQLException`][customer-42] to demonstrate how the `Result` type can
|
|
|
|
[map internal program errors][update-customer-error] to more appropriate
|
2018-01-24 13:21:01 +00:00
|
|
|
user-facing errors.
|
|
|
|
|
2017-10-21 19:03:51 +00:00
|
|
|
## Contributing
|
|
|
|
|
|
|
|
Bug reports and pull requests are welcome on [GitHub][github].
|
|
|
|
|
|
|
|
## License
|
|
|
|
|
|
|
|
This project is available under the terms of the ISC license. See the
|
|
|
|
[`LICENSE`](LICENSE) file for the copyright information and licensing terms.
|
|
|
|
|
2024-03-02 17:13:29 +00:00
|
|
|
[result]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L10
|
2024-03-16 20:11:19 +00:00
|
|
|
[result-value]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L55
|
|
|
|
[result-error]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L59
|
|
|
|
[result-Ok]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L9
|
|
|
|
[result-Err]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt#L17
|
|
|
|
[kotlin-inline-classes]: https://kotlinlang.org/docs/inline-classes.html
|
|
|
|
[wiki-Overhead]: https://github.com/michaelbull/kotlin-result/wiki/Overhead
|
|
|
|
[rop]: https://fsharpforfunandprofit.com/rop/
|
|
|
|
[kotlin-native-target-support]: https://kotlinlang.org/docs/native-target-support.html
|
|
|
|
[github]: https://github.com/michaelbull/kotlin-result
|
2017-10-21 18:44:58 +00:00
|
|
|
[wiki]: https://github.com/michaelbull/kotlin-result/wiki
|
2024-03-16 20:11:19 +00:00
|
|
|
[result-runCatching]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Factory.kt#L11
|
|
|
|
[result-binding]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Binding.kt#L28
|
|
|
|
[bow-bindings]: https://bow-swift.io/docs/patterns/monad-comprehensions/#bindings
|
|
|
|
[result-coroutineBinding]: https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/CoroutineBinding.kt#L42
|
|
|
|
[kotlin-coroutineScope]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html
|
2020-09-18 05:19:35 +00:00
|
|
|
[unit-tests]: https://github.com/michaelbull/kotlin-result/tree/master/kotlin-result/src/commonTest/kotlin/com/github/michaelbull/result
|
2018-01-24 13:21:01 +00:00
|
|
|
[example]: https://github.com/michaelbull/kotlin-result/tree/master/example/src/main/kotlin/com/github/michaelbull/result/example
|
|
|
|
[swalschin-example]: https://github.com/swlaschin/Railway-Oriented-Programming-Example
|
|
|
|
[ktor]: http://ktor.io/
|
2020-09-18 05:19:35 +00:00
|
|
|
[customer-42]: https://github.com/michaelbull/kotlin-result/blob/master/example/src/main/kotlin/com/github/michaelbull/result/example/repository/InMemoryCustomerRepository.kt#L38
|
2018-01-24 13:24:56 +00:00
|
|
|
[update-customer-error]: https://github.com/michaelbull/kotlin-result/blob/master/example/src/main/kotlin/com/github/michaelbull/result/example/service/CustomerService.kt#L50
|
2022-04-15 15:44:34 +00:00
|
|
|
|
|
|
|
[badge-android]: http://img.shields.io/badge/-android-6EDB8D.svg?style=flat
|
2023-01-05 23:48:20 +00:00
|
|
|
[badge-android-native]: http://img.shields.io/badge/support-[AndroidNative]-6EDB8D.svg?style=flat
|
2022-04-15 15:44:34 +00:00
|
|
|
[badge-jvm]: http://img.shields.io/badge/-jvm-DB413D.svg?style=flat
|
|
|
|
[badge-js]: http://img.shields.io/badge/-js-F8DB5D.svg?style=flat
|
|
|
|
[badge-js-ir]: https://img.shields.io/badge/support-[IR]-AAC4E0.svg?style=flat
|
|
|
|
[badge-nodejs]: https://img.shields.io/badge/-nodejs-68a063.svg?style=flat
|
|
|
|
[badge-linux]: http://img.shields.io/badge/-linux-2D3F6C.svg?style=flat
|
|
|
|
[badge-windows]: http://img.shields.io/badge/-windows-4D76CD.svg?style=flat
|
|
|
|
[badge-wasm]: https://img.shields.io/badge/-wasm-624FE8.svg?style=flat
|
|
|
|
[badge-apple-silicon]: http://img.shields.io/badge/support-[AppleSilicon]-43BBFF.svg?style=flat
|
|
|
|
[badge-ios]: http://img.shields.io/badge/-ios-CDCDCD.svg?style=flat
|
|
|
|
[badge-mac]: http://img.shields.io/badge/-macos-111111.svg?style=flat
|
|
|
|
[badge-watchos]: http://img.shields.io/badge/-watchos-C0C0C0.svg?style=flat
|
|
|
|
[badge-tvos]: http://img.shields.io/badge/-tvos-808080.svg?style=flat
|