A type for representing the result of an operation, capturing either a success or failure with a value or error details, with optional support for a taxonomy of status codes.
Part of the Kiit framework Β· kiit.dev/result Β· Blog post Β· Video walkthrough
| Topic | Description |
|---|---|
| Overview | |
| βΉοΈ About | What kiit.result is and how it relates to kiit-codes |
| π§© The problem | Why exceptions and nullable returns don't compose well for expected failure |
| π‘ The idea | A Result<T, E> monad built directly on kiit-codes' status taxonomy |
| Start | |
| π Quick start | Install the library and see Outcome, builders, and conversions |
| Reference | |
| π§ Core concepts | Result/Success/Failure, Action, the type aliases, and the builders |
| ποΈ Builders | The status-aware restricted/invalid/rejected/unserved/excluded factories |
| π Conversions | toOutcome()/toTry() and interop with kiit-codes' StatusException |
| Guidance | |
| π οΈ Use cases | Where this fits β services, pipelines, validation |
| β When to use this | Good-fit and not-necessary scenarios |
| β FAQ | Design rationale, comparisons to alternatives, adoption, and maturity |
| Project | |
| π¦ Requirements | Supported platforms and dependencies |
| πΊοΈ Roadmap | Publishing pipelines and CI work planned but not yet done |
| π€ Contributing | How to build, test, and submit changes |
| π License | Licensing terms for this project |
kiit.result is a Result<T, E> type for Kotlin Multiplatform β similar to Result in Rust and Swift, or Try in Scala. It models the outcome of an operation as one of two branches, Success<T> or Failure<E>, each carrying an optional kiit-codes Status so a caller can inspect why, not just whether.
Modeling an operation this way means answering four separable questions, not one:
- Did it work? β
Success<T>orFailure<E>. - What kind of outcome was it? β
status: Status, a closed taxonomy from kiit-codes (Succeeded,Restricted,Invalid,Rejected, ...). - What went wrong, specifically? β the
Failurebranch'serror: E, most commonly kiit-codes'Err, carrying per-instance detail a fixed status can't. - What was being done, and under what circumstances? β an optional
action: Action?, naming the operation and any correlation id/attributes, attached viawithAction.
It builds directly on kiit-codes rather than reimplementing status classification:
- A monadic
Result<T, E>βmap,flatMap/then,fold,onSuccess/onFailure,getOrElse, and friends, so success/failure handling composes without manualif/elsebranching. - A flexible error type β the
Failurebranch's error typeEcan be anything:String,Throwable, kiit-codes'Err, or your own domain type. Type aliases (Try<T>,Option<T>,Outcome<T>) cover the common cases. - Status-aware builders β
restricted/invalid/rejected/unserved/excludedbuild aResultpre-populated with the matching kiit-codes status category, so you rarely constructSuccess/Failureby hand.
val outcome: Outcome<User> = userService.create("alice", "alice@example.com")
outcome.fold(
{ user -> println("created ${user.id}") },
{ err -> println("failed: ${err.message} (${outcome.status.name})") },
)Returning null for "not found" loses the reason. Throwing for expected, recoverable failures (validation, a conflict, an unauthorized caller) is expensive and easy to over- or under-catch. And once you do return a status/error pair by convention, every caller ends up re-deriving the same success/failure branching logic by hand.
A Result<T, E> that composes the usual monadic operations with kiit-codes' closed status taxonomy, instead of a bespoke or numeric status of its own. Success carries a kiit-codes Passed status (Succeeded, Pending, Excluded, Information); Failure carries a Failed status (Restricted, Invalid, Rejected, Unserved). Builders map each common case to its matching category, so restricted() gives you Restricted.DENIED, invalid() gives you Invalid.INVALID_VALUE, and so on β without hand-rolling a status object at every call site.
Gradle (Kotlin DSL):
dependencies {
implementation("dev.kiit:kiit-result:0.1.0")
}kiit-result depends on dev.kiit:kiit-codes transitively β you don't need to add it separately.
Return an Outcome<T> (Result<T, Err>) using the builder methods:
import kiit.codes.Invalid
import kiit.codes.Rejected
import kiit.result.Outcome
import kiit.result.OutcomeBuilder
class UserService : OutcomeBuilder {
private val users = mutableMapOf<String, User>()
fun create(id: String, email: String): Outcome<User> {
if (email.isBlank()) return invalid(Invalid.BAD_REQUEST)
if (users.containsKey(id)) return rejected(Rejected.CONFLICT)
val user = User(id, email)
users[id] = user
return success(user)
}
}Compose with map/flatMap/fold:
import kiit.result.flatMap
userService.create("alice", "alice@example.com")
.map { it.email }
.onSuccess { println("registered: $it") }
.onFailure { err -> println("could not register: ${err.message}") }Convert to a Try<T> to cross an exception-only boundary:
// Wraps a Failure<Err> into a Failure<StatusException> from kiit-codes
val asTry = userService.fetch("missing").toTry()
asTry.onFailure { ex -> println("caught: ${ex.message}") }See samples/sample-kotlin for a runnable end-to-end Kotlin example, or
samples/sample-java for the same library used from plain Java.
Swift: not yet distributed via SPM/XCFramework (the framework is .framework-only today,
built locally). Companion-less members like Outcomes/Options/Tries get clean .shared
access out of the box, and this module uses SKIE for real,
compiler-enforced Swift exhaustiveness over Success/Failure β a genuinely flat switch,
simpler than kiit-codes' nested Status case, since Result<T, E> is only one sealed level deep:
import KiitResult
let result = Success(value: KotlinInt(value: 42))
func describe<T, E>(_ r: Result<T, E>) -> String {
switch onEnum(of: r) {
case .success(let s): return "ok: \(String(describing: s.value))"
case .failure(let f): return "err: \(String(describing: f.error))"
}
}Generic type params require AnyObject (box Int/String as KotlinInt/NSString), and
Kotlin's Nothing doesn't widen to a concrete error type in Swift β see
samples/sample-swift for the full, verified-working subset and exactly
what does and doesn't work (including a confirmed-broken case: flatMap can't be used from Swift
to construct new results).
Result<T, E> = Success<T> | Failure<E>
Success<T>.status : Passed (from kiit-codes)
Failure<E>.status : Failed (from kiit-codes)
Result<T, E>.action : Action? (optional, both branches)
| Term | What it is |
|---|---|
Result<T, E> |
Sealed type, either Success<T> or Failure<E>. |
Success<T> |
Holds a value: T and a status: Passed. Defaults to Succeeded.SUCCESS. |
Failure<E> |
Holds an error: E and a status: Failed. Defaults to Unserved.UNEXPECTED. |
Action |
Optional context for the operation that produced/wrapped a Result β action: String, xid: String? = null, data: Map<String, String> = mapOf(), previous: Action? = null. Attach via withAction(action, chain = true); chains to any existing Action by default, useful for pinpointing which layer failed in nested operations. |
message |
result.status.message β a convenience accessor on every Result. |
Option<T> |
Result<T, Unit> β the historical role of Option/Maybe (Rust/Scala/Arrow), reimagined on Result so absence carries a status explaining why, not just a bare None. Options.some(value)/Options.none() are the discoverable entry points. |
Try<T> |
Result<T, Throwable> β exception as the error type. |
Outcome<T> |
Result<T, Err> β kiit-codes' Err as the error type; the most commonly used alias. |
Validated<T> |
Result<T, Err.ErrorList> β for validation, collecting multiple errors. |
Composition operators mirror what you'd expect from Result/Either in other languages: map, mapError, flatMap/then, fold, exists, getOrNull, getOrElse, onSuccess, onFailure, transform, contains, inner (flattens a nested Result), plus or/and/operate for combining two Results, and withStatus/withAction for attaching a status/operation context after construction. Once attached, action survives map/mapError/toOutcome()/toTry(), the same as status does.
Builder<E> provides status-aware factory methods so you rarely build Success/Failure directly. It's composed from two smaller interfaces, one per branch, so each stays scoped to its own category constants (the same reason kiit-codes keeps Succeeded/Restricted/etc. constants on their own companions rather than one shared object):
PassedBuilder<E>βsuccess/pending/excluded, each with 3 overloads: no-arg,(value, message: String? = null), and(value, status).FailedBuilder<E>βrestricted/invalid/rejected/unserved, each with 5 overloads: no-arg,(message),(ex, status?),(err, status?),(status).
Builder/PassedBuilder/FailedBuilder live in kiit.result.builders β they're the extensible machinery you implement (directly, or via Outcomes/Options/Tries), not something most callers import directly. Outcomes/Options/Tries themselves stay in kiit.result, alongside Result/Success/Failure, since those are the ready-made, everyday API.
| Builder | Status category | Default |
|---|---|---|
success(value) |
Passed.Succeeded |
Succeeded.SUCCESS |
pending(value) |
Passed.Pending |
Pending.ACCEPTED |
excluded(value) |
Passed.Excluded |
Excluded.SKIPPED |
restricted(...) |
Failed.Restricted |
Restricted.DENIED |
invalid(...) |
Failed.Invalid |
Invalid.INVALID_VALUE |
rejected(...) |
Failed.Rejected |
Rejected.RULE_VIOLATION |
unserved(...) |
Failed.Unserved |
Unserved.UNEXPECTED |
Note that excluded() builds a Success, not a Failure β an intentionally excluded/skipped item (deduplicated, disqualified, filtered out) is a kiit-codes Passed.Excluded status, not a failure. There's no separate conflict() β it's rejected(status = Rejected.CONFLICT), since a conflict is just a specific Rejected outcome, not its own category.
Options also adds some(value)/none(...) on top of the generic builders above β a discoverable Some/None-style pair for Option<T> specifically (see Core concepts). none() defaults to Rejected.NOT_EXISTS, distinct from the generic Unserved.UNEXPECTED fallback:
import kiit.result.Options
val a = Options.some(42) // Option<Int> β present
val b = Options.none<Int>() // Option<Int> β absent, Rejected.NOT_EXISTS
val c = Options.none<Int>(Rejected.CONFLICT) // Option<Int> β absent, custom statusOutcomes/Options/Tries are the three ready-made Builder implementations, one per common error type:
import kiit.result.Outcomes
import kiit.result.Options
import kiit.result.Tries
val a = Outcomes.attempt { riskyCall() } // Outcome<T> β catches Throwable, wraps as Err
val b = Options.of { riskyCall() } // Option<T> β catches Throwable, discards detail
val c = Tries.attempt { riskyCall() } // Try<T> β catches Throwable, re-derives status
// from a thrown kiit-codes StatusExceptiontoOutcome()β converts anyResult<T, E>toOutcome<T>(Result<T, Err>), building anErrfrom whatever the failure held (String,Exception, or an existingErr).toTry()β converts anyResult<T, E>toTry<T>(Result<T, Throwable>). AnErr-typed failure becomes a kiit-codesStatusExceptionviaFailed.toException(errors), so the exception still carries the original status and error detail.Tries.of { ... }β the reverse direction: if the block throws aStatusException(RestrictedException/InvalidException/RejectedException/UnservedException), the resultingTryis built with the matchingrestricted/invalid/rejected/unservedstatus instead of a generic failure.
- Service layers β return
Outcome<T>instead of throwing for expected failures. - Pipelines β
map/flatMapchains compose without manual null/exception checks at each step. - Validation β
Validated<T>(Result<T, Err.ErrorList>) collects multiple errors. - Exception boundaries β
toTry()/Tries.ofinterop withStatusExceptionwhen a caller only understands exceptions. - HTTP/gRPC responses β
result.statusconverts via kiit-codes'CodesToHttp/CodesToGrpc.
Good fit if:
- You want explicit, monadic return values instead of throw/catch for expected failures.
- You're already using (or want) kiit-codes' status taxonomy and want a
Resulttype layered on top of it, instead of a bespoke one. - You need to compose several fallible steps (
map/flatMap) without nestedtry/catch.
Probably not necessary if:
- Exceptions already communicate everything you need, and you don't want the monadic-return-value style.
- You only need status classification, not a
Resultwrapper β in which case see kiit-codes on its own.
Note: this FAQ predates the multiplatform export work β
@JsExport/@JsName(JS/TS) and SKIE (iOS/Swift) are both applied now; see the JS/iOS answer below for current status.
| Question | Answer |
|---|---|
| Philosophy & Design | |
Why not just use Arrow's Either/Validated or kotlin-result? |
Those give you a monad with zero built-in taxonomy β you supply the meaning yourself. kiit-result is the same kind of monad fused to kiit-codes' taxonomy, so you get consistency across a codebase without every team inventing its own status vocabulary. A different bet, not a "better generic Result." |
Why does Success carry a status too, not just Failure? |
Most Result types treat success as inert β just a value. Here Success.status: Passed distinguishes "succeeded," "succeeded but pending," and "succeeded but excluded" instead of flattening them all to true. |
Why is E still fully generic instead of locked to kiit-codes' Err? |
So Try<T>, Option<T>, Outcome<T>, and Validated<T> can all share one Result<T, E> rather than needing separate types. The cost is nothing ties Failure.status to Failure.error at compile time β deliberately accepted, not fixed. |
Doesn't decoupling status from error risk them disagreeing? |
Yes, narrowly β only if you bypass the builders or explicitly override status against an unrelated error. The ergonomic path (restricted(err), etc.) already pairs them correctly by default. |
Why two ways to build a value (constructor vs. Builder<E>) instead of one? |
They serve different situations: the constructor is for no-ceremony construction with no Builder in scope; Builder<E> is the status-aware convenience path when implementing Outcomes/Options/Tries or your own class. |
| Comparisons & Alternatives | |
How is this different from Kotlin's own kotlin.Result? |
stdlib Result has one type param and always uses Throwable as the error; it isn't a sealed hierarchy meant for pattern matching. kiit-result is a real two-branch sealed type with a flexible error type and a status on both branches. |
Isn't Option<T> = Result<T, Unit> a strange use of the name "Option"? |
It's a deliberate lineage, not a misuse β the same historical role as Rust/Scala/Arrow's Option (standing in for a nullable value), reimagined so absence carries a status explaining why instead of a bare None. Options.some(value)/Options.none() make that explicit. |
| Is this tied to HTTP or web APIs? | No β it's a universal classification usable at any layer (service call, job step, CLI command), validated against HTTP and gRPC as an external sanity check, not derived from either. |
| API & Design Details | |
Why is there no conflict() builder? |
It was just rejected() with Rejected.CONFLICT as the default status β not its own category. Use rejected(status = Rejected.CONFLICT). |
Why did denied/ignored become restricted/excluded? |
To match kiit-codes' actual category names (Restricted, Excluded) instead of carrying forward older, inconsistent naming. |
Why does excluded() build a Success, not a Failure? |
Excluded is a Passed category in kiit-codes β an intentionally skipped/deduplicated/disqualified item isn't a failure. |
Why is Builder<E> split into PassedBuilder/FailedBuilder? |
Keeps each interface's surface scoped to one branch β the same reason kiit-codes keeps each category's constants on its own companion rather than one shared object. |
Do I have to pick a specific Status every time I use a builder? |
No β the group builders (restricted, invalid, rejected, unserved, and pending/excluded on the success side) all apply a sensible default when you don't supply one: restricted() β Restricted.DENIED, invalid() β Invalid.INVALID_VALUE, rejected() β Rejected.RULE_VIOLATION, unserved() β Unserved.UNEXPECTED. You only reach for an explicit status when the default doesn't fit (restricted(status = Restricted.LOCKED)) β routine use never requires touching Status directly. |
| Whatever happened to the numeric status code? | Dropped, mirroring kiit-codes' own removal β an earlier version had one and it invited the wrong inference (looks like an HTTP code, isn't). Get a protocol code on demand via CodesToHttp/CodesToGrpc. |
| Adoption in Practice | |
| Can I use my own error type and ignore kiit-codes? | Only partially β E is generic (use Throwable, String, your own type), but Success.status/Failure.status are hard-typed to kiit-codes' Passed/Failed. There's no way to use Result<T, E> without a kiit-codes status on every branch. |
| What if my team already has its own status conventions? | Not an overnight replacement β existing statuses can map into the taxonomy incrementally. |
| Does this actually work on JS and iOS today? | Worth being precise here: kiit-result's production history (see Maturity below) is JVM/Android β JS and iOS/Swift are new targets with no production history yet, not just "unexercised" versions of something proven. JS/TS is a deliberately partial pass β Result/Success/Failure/Action/the builder interfaces are @JsExported, but it's not CI-gated or published to npm (see samples/sample-ts), since TypeScript can't compiler-enforce exhaustiveness the way Kotlin/Java/Swift can. iOS uses SKIE for real, compiler-enforced Swift exhaustiveness (see samples/sample-swift) β a materially better story than JS here, including plain Kotlin objects (Outcomes/Options/Tries) getting clean .shared access with no extra work, unlike JS. Multiplatform-designed, years-proven on JVM/Android, newer on JS/iOS. |
| The AI Angle | |
| Is the "built for AI" angle just marketing? | Same answer as kiit-codes gives, extended to the Result layer: the design choices are justified on ordinary engineering grounds first β exhaustive branching, a small fixed vocabulary, fewer decisions per call site. AI tooling benefits from the same properties any consistent codebase does, but the library stands on its own without that framing. |
| What's the actual theory? | A closed Success/Failure split with a fixed, named-category vocabulary (restricted/invalid/rejected/unserved/excluded) gives an AI generating or reading code a small, predictable set of shapes to reach for, instead of guessing at ad hoc exception types or boolean flags per call site β and Kotlin's compiler-enforced exhaustive when over Success/Failure means a branch can't be silently missed, by a human or a model. Better accuracy, searchability, and standardization across a codebase are the claimed benefits β not proven, and intentionally modest about that, same as kiit-codes. |
| Maturity & Trust | |
| Is this production-ready at 0.1.0? | The 0.1.0 version reflects the standalone repo's age, not the design's β this Result<T, E> pattern, paired with a status taxonomy, has been running in production for years across both mobile and server applications inside the original kiit framework. What's actually new: extraction into an independent repo, decoupled from the kiit monorepo; an updated and polished taxonomy in kiit-codes (the category renames happened during this extraction); and, notably, kiit-codes and kiit-result are now fully decoupled from each other where they were previously coupled in one module. The multiplatform export work is the one piece that's genuinely in progress, not battle-tested. |
| What about single-maintainer risk? | Real, worth being upfront about β Apache 2.0, source available, no second maintainer or organizational backing yet. |
- Kotlin Multiplatform β JVM, Android, JS (IR), iOS (arm64, simulator arm64, x64)
- Depends on
dev.kiit:kiit-codes(transitively available to consumers viaapi)
- npm publish pipeline for JS consumers (
@kiit/result) - SPM / XCFramework pipeline for Swift consumers β SKIE is now applied for real Swift
exhaustiveness (see Quick Start above and
samples/sample-swift), but distribution itself (an actual.xcframework+ SPM package, publishing tokiit-spm) is still unbuilt - Diagrams and a fuller FAQ, matching kiit-codes' README
-
Raise<E>-style DSL (result { }+.bind()) as a flat, non-nested alternative to.then { }chaining for multi-step composition β needs Kotlin context parameters, which are experimental as of 2.3.x and reach Stable in 2.4.0
Track progress or open a discussion in Issues.
Contributions are welcome β see BUILD.md for build, test, and publish instructions.
kiit.result is one module of Kiit β a lightweight, modular, 100% Kotlin framework for building server apps, APIs, CLIs, and jobs. Adopt one module at a time.
