
Type modeling operation outcomes as Success or Failure with status taxonomy, monadic composition (map/flatMap/fold), status-aware builders and optional action context for richer error handling.
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:
Success<T> or Failure<E>.status: Status, a closed taxonomy from kiit-codes (Succeeded, Restricted, Invalid, Rejected, ...).Failure branch's error: E, most commonly kiit-codes' Err, carrying per-instance detail a fixed status can't.action: Action?, naming the operation and any correlation id/attributes, attached via withAction.It builds directly on kiit-codes rather than reimplementing status classification:
Result<T, E> — map, flatMap/then, fold, onSuccess/onFailure, getOrElse, and friends, so success/failure handling composes without manual if/else branching.Failure branch's error type E can be anything: String, Throwable, kiit-codes' Err, or your own domain type. Type aliases (Try<T>, Option<T>, Outcome<T>) cover the common cases.restricted/invalid/rejected/unserved/excluded build a Result pre-populated with the matching kiit-codes status category, so you rarely construct Success/Failure by 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 any Result<T, E> to Outcome<T> (Result<T, Err>), building an Err from whatever the failure held (String, Exception, or an existing Err).toTry() — converts any Result<T, E> to Try<T> (Result<T, Throwable>). An Err-typed failure becomes a kiit-codes StatusException via Failed.toException(errors), so the exception still carries the original status and error detail.Tries.of { ... } — the reverse direction: if the block throws a StatusException (RestrictedException/InvalidException/RejectedException/UnservedException), the resulting Try is built with the matching restricted/invalid/rejected/unserved status instead of a generic failure.Outcome<T> instead of throwing for expected failures.map/flatMap chains compose without manual null/exception checks at each step.Validated<T> (Result<T, Err.ErrorList>) collects multiple errors.toTry()/Tries.of interop with StatusException when a caller only understands exceptions.result.status converts via kiit-codes' CodesToHttp/CodesToGrpc.Good fit if:
Result type layered on top of it, instead of a bespoke one.map/flatMap) without nested try/catch.Probably not necessary if:
Result wrapper — 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. |
dev.kiit:kiit-codes (transitively available to consumers via api)@kiit/result)samples/sample-swift), but distribution itself
(an actual .xcframework + SPM package, publishing to kiit-spm) is still unbuiltRaise<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.0Track 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.
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:
Success<T> or Failure<E>.status: Status, a closed taxonomy from kiit-codes (Succeeded, Restricted, Invalid, Rejected, ...).Failure branch's error: E, most commonly kiit-codes' Err, carrying per-instance detail a fixed status can't.action: Action?, naming the operation and any correlation id/attributes, attached via withAction.It builds directly on kiit-codes rather than reimplementing status classification:
Result<T, E> — map, flatMap/then, fold, onSuccess/onFailure, getOrElse, and friends, so success/failure handling composes without manual if/else branching.Failure branch's error type E can be anything: String, Throwable, kiit-codes' Err, or your own domain type. Type aliases (Try<T>, Option<T>, Outcome<T>) cover the common cases.restricted/invalid/rejected/unserved/excluded build a Result pre-populated with the matching kiit-codes status category, so you rarely construct Success/Failure by 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 any Result<T, E> to Outcome<T> (Result<T, Err>), building an Err from whatever the failure held (String, Exception, or an existing Err).toTry() — converts any Result<T, E> to Try<T> (Result<T, Throwable>). An Err-typed failure becomes a kiit-codes StatusException via Failed.toException(errors), so the exception still carries the original status and error detail.Tries.of { ... } — the reverse direction: if the block throws a StatusException (RestrictedException/InvalidException/RejectedException/UnservedException), the resulting Try is built with the matching restricted/invalid/rejected/unserved status instead of a generic failure.Outcome<T> instead of throwing for expected failures.map/flatMap chains compose without manual null/exception checks at each step.Validated<T> (Result<T, Err.ErrorList>) collects multiple errors.toTry()/Tries.of interop with StatusException when a caller only understands exceptions.result.status converts via kiit-codes' CodesToHttp/CodesToGrpc.Good fit if:
Result type layered on top of it, instead of a bespoke one.map/flatMap) without nested try/catch.Probably not necessary if:
Result wrapper — 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. |
dev.kiit:kiit-codes (transitively available to consumers via api)@kiit/result)samples/sample-swift), but distribution itself
(an actual .xcframework + SPM package, publishing to kiit-spm) is still unbuiltRaise<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.0Track 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.