kiit-result
A Kotlin Result<T, E> that also tells you the kind of success and failure.
Every Success and Failure carries a status classifying the kind of outcome, unlike Rust's Result, Swift's Result, or other Kotlin Result types, where success is just a bare value. A Success might be a plain success, a pending operation, or an intentional skip; a Failure might be unauthorized, invalid input, or a conflict. That status comes from kiit-codes, an already-established taxonomy reused here instead of inventing a new one. Outcome<T> is the ready-made alias for everyday use, paired with Try<T>, Option<T>, and Validated<T> for exceptions, absence, and validation.

Overview
Goals
kiit-result's Result<T, E> exists so a caller knows the kind of success or failure, not just whether one happened. A status rides on both branches, not just Failure, closing a gap that null and thrown exceptions both leave unfilled: neither can say why something failed, or distinguish a completed success from an acceptable exclusion such as a duplicate.
Outcome<T>, Try<T>, Option<T>, and Validated<T> are simply aliases on this one type, ensuring a consistent approach. Each just sets the error shape for its respective use case.
A set of builder functions pick the right status for you: call restricted() for an unauthorized caller, invalid() for bad input, and so on, so the status matches the error without having to build one by hand. See Philosophy for the full rationale.
Inspiration
| Source | What it contributes |
|---|---|
| Scala | Either/Try, precedent for a flexible-error-type Result distinct from a bare Option |
| Rust | Result<T, E>'s two-branch monadic shape, and most of the operators (map, and_then, unwrap) |
| kotlin-result | Kotlin-idiomatic naming for that same operator set (getOr, recover, combine, partition) |
| kiit-codes | The closed status taxonomy on both branches, kiit-result's actual differentiator from every Result type |
Activity
kiit-result has been extracted from the Kiit toolkit and polished as a standalone module. This Result<T, E> pattern, paired with a status taxonomy, has been running in production for over 4 years across mobile and server Kotlin applications. Current work is focused on the Kotlin Multiplatform release, documentation, examples, and ecosystem integration. See GitHub Issues for what's in flight.
Resources
| # | Resource | Link |
|---|---|---|
| 1 | Repository | github.com/kiitdev/kiit-result |
| 2 | Maven Central | dev.kiit:kiit-result |
| 3 | npm | Not yet published. JS/TS is a partial pass not covered by CI, see Limitations |
| 4 | Related module | kiit-codes, the status taxonomy this library builds on |
| 5 | Samples | samples/sample-kotlin, sample-java, sample-swift, sample-ts in the repo |
Setup
Install
dependencies {
implementation("dev.kiit:kiit-result:1.0.1")
}
kiit-result depends on dev.kiit:kiit-codes transitively. No separate dependency is needed.
Source
| # | What | Links |
|---|---|---|
| 1 | Source | kiit-result/src/commonMain |
| 2 | Package | kiit.result |
| 3 | Samples | samples/ |
| 4 | Tests | kiit-result/src/commonTest |
Example
import kiit.codes.Err
import kiit.codes.Rejected
import kiit.codes.Restricted
import kiit.result.Outcome
import kiit.result.Outcomes
class UserService {
private val users = mutableMapOf<String, User>()
// Alias Outcome<User> = Result<User, Err>
// Err is an error type from kiit-codes.
fun create(id: String, email: String): Outcome<User> = when {
// Restricted: a reserved id, not allowed
id == "admin" -> Outcomes.restricted(Restricted.DENIED)
// Invalid: bad input
email.isBlank() -> Outcomes.invalid(Err.on("email", email, "email is required"))
// Rejected: already exists
users.containsKey(id) -> Outcomes.rejected(Rejected.CONFLICT)
// Succeeded: created
else -> {
val user = User(id, email)
users[id] = user
Outcomes.success(user)
}
}
}
userService.create("alice", "alice@example.com")
.map { it.email }
.onSuccess { println("registered: $it") }
.onFailure { err -> println("could not register: ${err.message}") }
See samples/sample-kotlin for a runnable end-to-end example, or samples/sample-java for the same library from plain Java.
Concepts
Terms
| Term | What it is | |
|---|---|---|
Result<T, E> | Sealed type, either Success<T> or Failure<E> | More |
Success<T> | Holds a value: T and a status: Passed | More |
Failure<E> | Holds an error: E and a status: Failed | More |
Action | Optional context for the operation that produced/wrapped a Result | More |
| Aliases | Option<T>/Try<T>/Outcome<T>/Validated<T>, type aliases fixing E for common cases | More |
| Operators | map/flatMap/fold/recover/..., the composition surface | More |
| Builders | Status-aware factory methods for Success/Failure | More |
| Conversions | toOutcome()/toTry(), crossing between error-type shapes | More |
| Status | kiit-codes' Passed/Failed taxonomy, attached to every Result | More |
- Start here: Adopt
Result<T, E>on its own first, the rest of the table above is optional. - Adopt gradually: Add
Status,Aliases,Builders, orActiononly when they solve a real problem you have.
Structure
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)
Result is a sealed type with exactly two subtypes:
- Success: holds a
value: T, defaults itsstatustoSucceeded.SUCCESS. - Failure: holds an
error: E, defaults itsstatustoUnserved.UNEXPECTED.
Success<T> doesn't always mean "complete," see Status for the full set of Passed kinds. result.message is a convenience accessor equal to result.status.message on either branch.

Status
Every Result carries a status, not just Failure. Success<T>.status is a Passed, Failure<E>.status is a Failed, each with four further subtypes:
| Subtypes | |
|---|---|
Passed | Succeeded, Pending, Excluded, Information |
Failed | Restricted, Invalid, Rejected, Unserved |
- Not "done":
Success<T>means aPassedstatus, not "the operation completed" - Still a success:
Pending(queued) orExcluded(intentionally skipped) are aSuccess, not a lesser kind of failure. - Independent facts:
statusanderrorare independent fields ofFailure, and don't have to agree. See Relationship. - Optional to manage: Builders apply a sensible default status when none is supplied, so nothing else is required.
This hierarchy belongs to kiit-codes, not kiit-result. See the kiit-codes docs for the full set of groups and codes. Using it

Action
Action names the operation that produced or wrapped a Result. It carries:
action: String: the operation name, required.xid: String?: an optional correlation id for context.data: Map<String, String>: optional free-form attributes.previous: Action?: an optional link to the action this one was chained from.
Attach it via result.withAction(action, chain = true). Chaining links to whatever action is already present by default, which is what makes it useful for pinpointing which layer failed inside a nested call chain. Once attached, action survives map/mapError/toOutcome()/toTry(), the same as status does.
- Scope:
Actionis a lightweight context bag (name, correlation id, attributes, previous link) by design. - Not observability: This is not meant to replace tracing/telemetry systems (e.g. OpenTelemetry).
- Use case: Most useful for debugging or response structures, showing the operational context associated with a result.

Aliases
Option<T>, Try<T>, Outcome<T>, and Validated<T> are practical specializations of one Result<T, E>, each just fixing E to a common error shape, not four separate types.

- Outcome: kiit-codes'
Erras the error type, the most commonly used alias. - Validated: For validation, collecting multiple errors instead of stopping at the first.
- Try: Exception as the error type, the shape used when crossing an exception-only boundary.
- Option: The historical
Option/Mayberole (Rust/Scala/Arrow), reimagined so absence carries astatusexplaining why, not just a bareNone.Options.some(value)/Options.none()are the entry points.
- Intention:
Validated<T>collects every error instead of stopping at the first, for validating a whole request at once. - Just an alias: Just a type alias on
Result<T, E>, not a separate applicative type. - Adaptation: Category-theory's
Validatedis typically its own accumulating-applicative abstraction, distinct fromEither. Here it's a practical specialization of the same monadicResult, not that abstraction.
- Intention:
Option<T>expresses presence of a value, not just success/failure. - Presence:
Success<T>means the value is present, a success of presence. - Absence:
Failure<Unit>means the value is absent, a failure of presence. - Adaptation: A deliberate adaptation of
Option/Maybe, not literal category-theory semantics. - Practicality: Familiar names, practical semantics, these aliases don't attempt to replicate category-theory abstractions.
Operators
Composition operators mirror what you'd expect from Result/Either in other languages, with kiit-codes' status/Action threaded through every one that returns a new Result:
| Operator | Purpose |
|---|---|
map, mapError | Transform the success value or error, leaving the other branch untouched |
flatMap/then, orElse | Chain another Result-returning step on the success or failure branch |
fold, transform | Collapse both branches to one value (fold), or to a new Result from either branch (transform) |
exists, existsError | Predicate check against the success value or the error, without unwrapping |
getOrNull, getErrorOrNull | Nullable accessors for the value or the error |
getOrElse, getOr | Value or a computed (getOrElse) / literal (getOr) fallback |
getOrThrow, getErrorOrThrow | Value/error or throw, with an optional custom message |
getOrRethrow | Value, or rethrow the original error unchanged (only when E : Throwable) |
onSuccess, onFailure | Side-effect on one branch, Result unchanged either way |
recover | Unconditionally turn a Failure into a Success |
flatten | Flatten a nested Result<Result<T, E>, E> |
or, and | Combine with a second Result, independent of any transform |
withStatus, withAction | Attach a status or operation context after construction |
A List<Result<T, E>> adds its own operators: combine() sequences the list into one Result<List<T>, E>, short-circuiting on the first Failure; partition() splits it into successes and errors; allSuccess/allFailure/anySuccess/anyFailure check the batch without building a new Result.
Builders
Builder<E> provides status-aware factory methods so Success/Failure are rarely built 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/information, 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. Extensible machinery to implement (directly, or via Outcomes/Options/Tries), not something most callers import directly. Outcomes/Options/Tries/Validations stay in kiit.result alongside Result/Success/Failure, since those are the ready-made, everyday API.
- Prefer builders: Use
restricted()/invalid()/... (viaOutcomes/Options/Tries) for everyday construction. - Constructor is advanced: The raw
Success/Failureconstructor is a no-ceremony escape hatch, not the default path.
| Builder | Status group | Default Code |
|---|---|---|
success(value) | Passed.Succeeded | Succeeded.SUCCESS |
pending(value) | Passed.Pending | Pending.ACCEPTED |
excluded(value) | Passed.Excluded | Excluded.OMITTED |
information(value) | Passed.Information | Information.NOTICE |
restricted(...) | Failed.Restricted | Restricted.DENIED |
invalid(...) | Failed.Invalid | Invalid.INVALID_VALUE |
rejected(...) | Failed.Rejected | Rejected.RULE_VIOLATION |
unserved(...) | Failed.Unserved | Unserved.UNEXPECTED |
import kiit.codes.Err
import kiit.codes.Rejected
import kiit.result.Outcomes
// Success(42), default status Succeeded.SUCCESS
val ok = Outcomes.success(42)
// Failure, default status Rejected.RULE_VIOLATION
val bad = Outcomes.rejected("duplicate entry")
// Failure, explicit status, default message
val conflict = Outcomes.rejected(Rejected.CONFLICT)
// Failure, plain message wrapped as Err, explicit status
val withMessage = Outcomes.rejected(Err.of("duplicate entry"), Rejected.CONFLICT)
// Failure, field-specific Err, explicit status
val withField = Outcomes.rejected(
Err.on("email", "a@b.com", "already registered"), Rejected.CONFLICT
)
Options also adds some(value)/none(...) on top of the generic builders above, a discoverable Some/None-style pair for Option<T> specifically. none() defaults to Rejected.NOT_EXISTS, distinct from the generic Unserved.UNEXPECTED fallback:
import kiit.codes.Rejected
import kiit.result.Options
// Option<Int> — present
val a = Options.some(42)
// Option<Int> — absent, Rejected.NOT_EXISTS
val b = Options.none<Int>()
// Option<Int> — absent, custom status
val c = Options.none<Int>(Rejected.CONFLICT)
Outcomes/Options/Tries are the three ready-made Builder implementations, one per common error type. Validations is a fourth, purpose-built for collecting multiple errors at once instead of catching an exception:
import kiit.result.Outcomes
import kiit.result.Options
import kiit.result.Tries
import kiit.result.Validations
// Outcome<T>: catches Throwable, wraps as Err
val a = Outcomes.attempt { riskyCall() }
// Option<T>: catches Throwable, discards detail
val b = Options.of { riskyCall() }
// Try<T>: catches Throwable, re-derives status from a thrown kiit-codes StatusException
val c = Tries.attempt { riskyCall() }
// Validated<T>: Success if errorsFound is empty, otherwise a single Failure carrying all of them
val d = Validations.of(form, errorsFound)
Conversions
toOutcome(): 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.
Design
Philosophy
kiit-result's design comes down to five ideas. Each builds on the one before it: Status classifies an outcome, Builders make that classification practical to produce, Aliases adapt the whole thing to common application shapes, Action adds optional context on top, and Scope states what's deliberately left out.
| # | Idea | Description |
|---|---|---|
| 1 | Status | 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 every success down to a bare true (see Status for the full set of kinds on each side). This is the one idea without a direct precedent in Rust, Swift, or kotlin-result, and the reason kiit-result exists as its own type rather than reusing one of those. |
| 2 | Builders | Builders like restricted(err), invalid(err), and rejected(err) pick a sensible default status for you, so the common case needs no separate classification step. status and error are independent facts about the same Failure, not a pair that has to agree, see Relationship. |
| 3 | Aliases | E stays fully generic rather than locked to kiit-codes' Err, so Outcome<T>, Try<T>, Option<T>, and Validated<T> can all share one Result<T, E> instead of needing four separate types. Each alias just fixes E to the error shape a given situation calls for, with a matching builder already wired up. Outcome<T> is the default reach for most code; Try<T> is for exception boundaries and compatibility specifically, not discouraged, just situational. |
| 4 | Action | An optional Action records which operation produced or wrapped a Result, and chaining links a new one to whatever was already there. It's the one idea that isn't about status at all, lightweight context for identifying which layer produced or failed a result inside a nested call chain. |
| 5 | Scope | kiit-result aims for a reasonable learning curve on everyday application code, not a category-theory-complete FP toolkit. Option/Try/Outcome/Validated are practical specializations of one Result<T, E>, familiar names with pragmatic semantics. Full category-theory abstractions (applicatives, monad transformers) are a different, valid goal, just not this library's. |
The same closed status vocabulary also shows up on both branches, a side benefit worth naming: a model reading or generating code against kiit-result has one exhaustive pattern to match against, on Success and Failure alike, rather than a bespoke shape per library.
There are also two ways to build a value: prefer Builder<E> (via Outcomes/Options/Tries) for everyday construction; the plain constructor is a no-ceremony escape hatch for when no Builder is in scope, not the default path.
- Status first:
Result<T, E>withStatusalone covers most needs, everything below is additive. - Builders next: Reach for
Outcomes/Options/Triesonce constructing values by hand gets repetitive. - Then as needed:
AliasesandActionare optional layers, adopt each only when it solves a real problem, in the same order they build on each other above.
Relationship
error: E and status: Status are both attributes of the same Failure, not two views of one fact that need to agree. Each answers a different question about the same occurrence:
status: what kind of outcome this is (see Status for the full set on each branch), for branching, protocol mapping (CodesToHttp/CodesToGrpc), and log severity.error: what happened, in whatever detail the situation calls for, a message, a field, a wrapped exception, or a domain-specific type.
The clearest comparison is an HTTP response: a 500 can carry "database timeout" or "null pointer in payment processing," and nobody considers it a defect that HTTP doesn't validate the body against the code. The status classifies the outcome; the body provides the specific details. status and error play the same two roles here.
Deriving status from error's content would also remove a real degree of freedom: the same Err can legitimately be Restricted in one call site and Rejected in another, depending on what the calling code is actually deciding, not a fact recoverable from the error text alone.
The one exception is Tries.of, which does derive status from error, specifically when the thrown Throwable is itself a kiit-codes StatusException. That particular error type already carries its own category by construction, so there's a real fact to derive. Otherwise, classification is a deliberate, independent choice, not something error's content could determine on its own.
Errors
Err is kiit-codes' own error representation, not kiit-result's, the default E for Outcome<T> (Result<T, Err>). It's a closed hierarchy with three shapes:
ErrorInfo: a message with an optional cause, the general-purpose default.ErrorField: a message tied to a specificfield/value, for validation-style errors.ErrorList: wraps multipleErrs into one, whatValidated<T>collects into.
Every Err carries a message: String, a cause: Throwable?, and a ref: Any? for attaching arbitrary context.
| Builder | Produces |
|---|---|
Err.of(message) | ErrorInfo from a plain message |
Err.on(field, value, message) | ErrorField with a value |
Err.on(field, message) | ErrorField without a value, for a sensitive field like a password |
Err.ex(throwable) | ErrorInfo wrapping a caught exception |
See the kiit-codes docs for the full API, including Err.build/Err.obj/Err.list. Err and status are independent facts about the same Failure, not a pair that has to agree, see Relationship.
Comparisons
kiit-result's operators aren't novel in themselves. Nearly every one has a direct precedent in Rust's Result, kotlin-result, or both. The actual differentiator is the status taxonomy fused onto both branches, not the operator surface:
| kiit-result | kotlin-result | Swift | Rust |
|---|---|---|---|
map | map | map | map |
mapError | mapError | mapError | map_err |
flatMap/then | flatMap/andThen | flatMap | and_then |
orElse | orElse | flatMapError | or_else |
or | or | — | or |
and | and | — | and |
fold | fold | — (use switch) | map_or_else |
transform | flatMapEither | — | — |
exists | — | — | is_ok_and |
existsError | — | — | is_err_and |
onSuccess/onFailure | onOk/onErr | — | inspect/inspect_err |
getOrNull/getErrorOrNull | get/getError | — | ok()/err() |
getOrElse | getOrElse | — | unwrap_or_else |
getOr | getOr | — | unwrap_or |
getOrThrow() | unwrap() | get() throws | unwrap() |
getOrThrow(message) | expect(message) | — | expect(msg) |
getErrorOrThrow() | unwrapError() | — | unwrap_err() |
getErrorOrThrow(message) | expectError(message) | — | expect_err(msg) |
getOrRethrow | getOrThrow() (E : Throwable) | — | — (no exceptions) |
recover | recover | — | — |
flatten | flatten | — | flatten (since 1.89.0) |
combine | combine | — | via collect::<Result<Vec<T>, E>>() |
partition | partition | — | — |
allSuccess/allFailure | allOk/allErr | — | — |
anySuccess/anyFailure | anyOk/anyErr | — | — |
Outcomes/Tries.attempt | runCatching | init(catching:) | — |
withStatus/withAction | — | — | — |
Swift's standard library Result is deliberately minimal: map/mapError/flatMap/flatMapError, a throwing get(), and init(catching:), with no native fold, onSuccess/onFailure, or getOrElse. Callers reach for switch or write their own extensions for those instead. A large share of Kotlin engineers come from mobile though, where this smaller Swift surface, not Rust's fuller one, is the more familiar reference point.
Features
| # | Feature | Description |
|---|---|---|
| 1 | Status on both branches | Every Success/Failure carries a status, not just failure. See Structure |
| 2 | Flexible error type | E can be String, Throwable, Err, or a domain type. See Aliases |
| 3 | Operation context | Optional Action records the operation across nested calls. See Action |
| 4 | Status-aware builders | restricted/invalid/... prepopulate the matching status. See Builders |
| 5 | Exception-boundary conversions | toTry()/Tries.of cross into and out of exceptions. See Conversions |
| 6 | List-combining operators | combine/partition/... work on a batch of Results. See Operators |
Limitations
| # | Limitation | Details |
|---|---|---|
| 1 | Swift distribution unbuilt | Not yet distributed via SPM/XCFramework. See Swift Interop |
| 2 | AI-angle claims unproven | Better accuracy/searchability from a closed vocabulary is the claimed benefit, not something measured |
| 3 | JS/TS is partial | @JsExported but not covered by CI or published to npm. TypeScript can't compiler-enforce exhaustiveness the way Kotlin/Java/Swift can |
Exclusions
| # | Excluded | Reasoning |
|---|---|---|
| 1 | Coroutine module | Cancellation-safety and concurrent composition are a different problem from sequential, status-aware composition. kiit-result's deferred Raise<E>/bind() roadmap item addresses the latter |
| 2 | zip/tryMap-style iterable mirrors | Large in volume, buildable from combine if actually needed. Not worth the surface area yet |
| 3 | Numeric status code | Dropped, mirroring kiit-codes' own removal. Invites the wrong inference (looks like an HTTP code, isn't); get a protocol code on demand via CodesToHttp/CodesToGrpc |
Tutorial
This tutorial uses a single createUser function that grows through each step below, layering on exactly the five ideas from Philosophy in order, then closing with a Responses mapping. No prior Concepts or Design knowledge required.
Result<T,E>
Before anything else, this is the mechanism everything below builds on: a Success<T> or a Failure<E>, built directly, using the default status, with no builder or alias in play yet.
import kiit.codes.Err
import kiit.result.Failure
import kiit.result.Result
import kiit.result.Success
import kiit.result.flatMap
import kiit.result.map
import kiit.result.onFailure
import kiit.result.onSuccess
data class User(val id: String, val email: String)
private val users = mutableMapOf<String, User>()
fun createUser(id: String, email: String): Result<User, Err> = when {
email.isBlank() -> Failure(Err.on("email", email, "email is required"))
users.containsKey(id) -> Failure(Err.of("user already exists"))
else -> {
val user = User(id, email)
users[id] = user
Success(user)
}
}
val result = createUser("alice", "alice@example.com")
// 1. Check: branch directly on the two shapes a Result<T, E> has
when (result) {
is Success -> println("registered: ${result.value.email}")
is Failure -> println("could not register: ${result.error.message}")
}
// 2. Operations: map/flatMap transform the value without leaving that shape
result.map { it.email }
// Success("alice@example.com")
result.flatMap { user -> Success(user.email) }
// Success("alice@example.com")
// 3. onSuccess/onFailure: run a side effect, return the Result unchanged either way
result
.onSuccess { user -> println("registered: ${user.email}") }
.onFailure { err -> println("could not register: ${err.message}") }
// registered: alice@example.com
Success/Failure are the only two shapes a Result<T, E> has, direct enough to when-branch on. map/flatMap transform the value without leaving that shape. onSuccess/onFailure run a side effect and return the Result unchanged either way. Every step below adds one more layer on top of exactly this, nothing underneath it changes.
Add the Status
Success(user) and Failure(err) above already carry a Status, just an unpicked default (Succeeded.SUCCESS, Unserved.UNEXPECTED). Naming it turns each branch into something a caller can classify and act on, not just succeed or fail:
import kiit.codes.Invalid
import kiit.codes.Rejected
import kiit.codes.Succeeded
fun createUser(id: String, email: String): Result<User, Err> =
// now attaching an explicit status to each branch
when {
email.isBlank() -> Failure(Err.on("email", email, "email is required"), Invalid.BAD_REQUEST)
users.containsKey(id) -> Failure(Err.of("user already exists"), Rejected.CONFLICT)
else -> {
val user = User(id, email)
users[id] = user
Success(user, Succeeded.CREATED)
}
}
status and error/value are independent facts about the same branch, see Relationship. See Concepts > Status for the full set of kinds on each side.
Add the Builders
Picking a status by hand for every branch gets repetitive once a function has more than one or two. Outcomes bundles the common cases into one call, each pre-filled with a sensible default status:
import kiit.result.Outcomes
fun createUser(id: String, email: String): Result<User, Err> =
// now using Outcomes builders instead of manual Success/Failure calls
when {
email.isBlank() -> Outcomes.invalid(Err.on("email", email, "email is required"))
users.containsKey(id) -> Outcomes.rejected(Err.of("user already exists"), Rejected.CONFLICT)
else -> {
val user = User(id, email)
users[id] = user
Outcomes.success(user, Succeeded.CREATED)
}
}
Outcomes.invalid(err) defaults to Invalid.INVALID_VALUE when no status is given, close enough here to leave out; rejected/success still pass theirs explicitly since CONFLICT/CREATED are more specific than those methods' own defaults (Rejected.RULE_VIOLATION/Succeeded.SUCCESS). See Concepts > Builders for the full method list.
Add the Alias
Every version of createUser above has actually been returning an Outcome<User> the whole time. Outcome<T> is just Result<T, Err> named for this common case. Swapping the signature is the entire change:
import kiit.result.Outcome
// same code, now returning Outcome<User> instead of Result<User, Err>
fun createUser(id: String, email: String): Outcome<User> = when {
email.isBlank() -> Outcomes.invalid(Err.on("email", email, "email is required"))
users.containsKey(id) -> Outcomes.rejected(Err.of("user already exists"), Rejected.CONFLICT)
else -> {
val user = User(id, email)
users[id] = user
Outcomes.success(user, Succeeded.CREATED)
}
}
Outcome<T> is the default reach for most application code (see Philosophy). At an exception boundary, Try<T> is the alias for that instead:
val asTry = createUser("alice", "alice@example.com").toTry()
asTry.onFailure { ex -> println("caught: ${ex.message}") }
toTry() converts a Failure<Err> into a Failure<StatusException>, the escape hatch for a caller that only understands exceptions. See Conversions for the full mapping.
Add the Action
An Action is optional context on top of everything so far, which operation produced this particular Result, useful once createUser is called from more than one place:
import kiit.result.Action
fun createUser(id: String, email: String): Outcome<User> =
when {
email.isBlank() -> Outcomes.invalid(Err.on("email", email, "email is required"))
users.containsKey(id) -> Outcomes.rejected(Err.of("user already exists"), Rejected.CONFLICT)
else -> {
val user = User(id, email)
users[id] = user
Outcomes.success(user, Succeeded.CREATED)
}
}
// now attaching an Action recording which operation produced this
.withAction(Action(action = ::createUser.name, data = mapOf("email" to email)))
val result = createUser("bob", "bob@example.com")
result.action?.action
// "createUser"
::createUser.name reads the function's own name via a property reference instead of a hand-typed string, so a rename can't leave a stale action behind. Action doesn't change status or the error/value at all, it's a lightweight, separate record. See Using Action for chaining across nested calls.
Add the Response (HTTP/gRPC)
createUser's status is now specific enough to map straight onto a transport response. Result itself stays out of that mapping, kiit-codes' CodesToHttp/CodesToGrpc do it instead:
import kiit.codes.CodesToHttp
val conflict = createUser("bob", "bob@example.com")
// map the result's status to an HTTP status code
val httpCode = CodesToHttp().toCode(conflict.status)
// 409, from Rejected.CONFLICT's own default
See Responses (HTTP/gRPC) for the full body shape, and Domain Errors for giving createUser its own custom status codes instead of kiit-codes' built-in ones.
Guide
Usage
- 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 viaValidations. - Exception boundaries:
toTry()/Tries.ofinterop withStatusExceptionwhen a caller only understands exceptions. - HTTP/gRPC responses:
result.statusconverts via kiit-codes'CodesToHttp/CodesToGrpc. See Responses (HTTP/gRPC).
Good fit if:
- Explicit, monadic return values instead of throw/catch for expected failures are wanted.
- kiit-codes' status taxonomy is already in use (or wanted), with a
Resulttype layered on top instead of a bespoke one. - Several fallible steps (
map/flatMap) need composing without nestedtry/catch.
Probably not necessary if:
- Exceptions already communicate everything needed, and the monadic-return-value style isn't wanted.
- Only status classification is needed, not a
Resultwrapper. See kiit-codes on its own.
Branching
Result is sealed with exactly two subtypes, so a when over it is exhaustive without a default/else branch, on Kotlin, and on Java 21's pattern-matching switch too.
import kiit.result.Failure
import kiit.result.Outcome
import kiit.result.Success
val result: Outcome<User> = userService.create("alice", "alice@example.com")
when (result) {
is Success -> println("created ${result.value.id}")
is Failure -> println("failed: ${result.error.message}")
}
Using Status
result.status is itself a closed hierarchy: Passed or Failed at the top, each with four further subtypes. Nesting a when inside a when gets exhaustiveness at both levels. Capture result.status into a local val first, so the compiler can smart-cast it reliably inside the nested when too. See Concepts > Status for the full set of subtypes.
- Not "done":
Success<T>means aPassedstatus, not "the operation completed" - Still a success:
Pending(queued) orExcluded(intentionally skipped) are aSuccess, not a lesser kind of failure. - Independent facts:
statusanderrorare independent fields ofFailure, and don't have to agree. See Relationship. - Optional to manage: Builders apply a sensible default status when none is supplied, so nothing else is required.
import kiit.codes.Excluded
import kiit.codes.Failed
import kiit.codes.Information
import kiit.codes.Invalid
import kiit.codes.Passed
import kiit.codes.Pending
import kiit.codes.Rejected
import kiit.codes.Restricted
import kiit.codes.Succeeded
import kiit.codes.Unserved
// result = userService.create("alice", "alice@example.com"), from Branching above
when (val status = result.status) {
is Passed -> when (status) {
is Succeeded -> println("succeeded: ${status.name}")
is Pending -> println("pending: ${status.name}")
is Excluded -> println("excluded: ${status.name}")
is Information -> println("info: ${status.name}")
}
is Failed -> when (status) {
is Restricted -> println("restricted: ${status.name}")
is Invalid -> println("invalid: ${status.name}")
is Rejected -> println("rejected: ${status.name}")
is Unserved -> println("unserved: ${status.name}")
}
}
Responses (HTTP/gRPC)
Result doesn't own transport mapping, that's kiit-codes' job. Map result.status through CodesToHttp/CodesToGrpc at the boundary where you actually need a status code, keeping the two concerns separate. See the kiit-codes docs for the full mapping tables.
import kiit.codes.CodesToGrpc
import kiit.codes.CodesToHttp
val http = CodesToHttp()
val grpc = CodesToGrpc()
// result = userService.create("alice", "alice@example.com"), from Branching above
// 201, from Succeeded.CREATED's override
http.toCode(result.status)
// 0, OK, every Passed status maps to 0
grpc.toCode(result.status)
The numeric code (409 here) becomes the actual HTTP status; the body carries kiit-codes' own status detail plus any field-level errors, for example, EMAIL_TAKEN (a custom Rejected code, see Domain Errors). Shape the body however fits your API, this is just one reasonable structure:
{
"status": {
"success": false,
"name": "EMAIL_TAKEN",
"group": "Rejected",
"origin": "users",
"message": "Email is already registered"
},
"errors": [
{ "field": "email", "message": "Duplicate email" }
]
}
Using Action
Attach an Action when a result is produced, then read it back for logging or debugging. Chaining links a new Action to whatever one was already there, so a caller several layers up can see the whole path an operation took. map/mapError carry an existing Action forward automatically; flatMap doesn't, since the new Result comes from caller-supplied code, so reattach it explicitly there if it needs to carry through.
- Scope:
Actionis a lightweight context bag (name, correlation id, attributes, previous link) by design. - Not observability: This is not meant to replace tracing/telemetry systems (e.g. OpenTelemetry).
- Use case: Most useful for debugging or response structures, showing the operational context associated with a result.
import kiit.result.Action
import kiit.result.Outcome
fun createUser(id: String, email: String): Outcome<User> =
userService.create(id, email)
.withAction(Action(action = ::createUser.name, xid = "req-42", data = mapOf("email" to email)))
val result = createUser("u1", "alice@example.com")
// "createUser"
result.action?.action
// "req-42"
result.action?.xid
// {"email": "alice@example.com"}
result.action?.data
// chaining: a new Action's `previous` links back to whatever Action was already there
val outer = result.withAction(Action(action = ::processOrder.name))
// "processOrder", the current operation
outer.action?.action
// "createUser", the operation this one wrapped
outer.action?.previous?.action
::createUser.name/::processOrder.name read each function's own name via a property reference instead of a hand-typed string, compile-safe against a rename that a plain string would silently miss.
Ops: Core
The everyday operators for composing and inspecting a Result without leaving its own shape: map transforms the success value, flatMap chains another Result-returning step, exists checks the value without unwrapping it, and onSuccess/onFailure run a side effect on whichever branch matches.
import kiit.result.Outcome
import kiit.result.Outcomes
import kiit.result.Success
import kiit.result.flatMap
val ok: Outcome<Int> = Outcomes.success(42)
val bad: Outcome<Int> = Outcomes.unserved("boom")
// Success("42 dollars")
ok.map { "$it dollars" }
// unchanged Failure, map skips it
bad.map { "$it dollars" }
// Success(43)
ok.flatMap { Success(it + 1) }
// unchanged Failure, flatMap short-circuits
bad.flatMap { Success(it + 1) }
// true
ok.exists { it > 0 }
// false, a Failure never satisfies exists
bad.exists { it > 0 }
// runs the block, returns ok unchanged
ok.onSuccess { println("got $it") }
// skipped, returns bad unchanged
bad.onFailure { err -> println("failed: ${err.message}") }
Every operator here is inline, so a suspend function call works fine inside their lambdas, sequential coroutine composition needs no special handling:
// fetchOrder and chargeCard are themselves suspend functions
suspend fun processOrder(orderId: String): Outcome<Receipt> =
Outcomes.attempt { fetchOrder(orderId) }
.flatMap { order -> chargeCard(order) }
Ops: Getters
Every accessor for pulling a value or error out of a Result lives in this family, nullable, defaulted, or throwing, depending on how the failure case should be handled. getOrNull/getErrorOrNull hand back null; getOr/getOrElse hand back a fallback (literal or computed from the error); getOrThrow/getErrorOrThrow/getOrRethrow throw instead, differing only in what gets thrown.
import kiit.result.Failure
import kiit.result.Outcome
import kiit.result.Outcomes
import kiit.result.Success
import kiit.result.Try
import kiit.result.getOr
import kiit.result.getOrElse
import kiit.result.getOrRethrow
val ok: Outcome<Int> = Outcomes.success(42)
val bad: Outcome<Int> = Outcomes.unserved("boom")
// 42
ok.getOrNull()
// null
bad.getOrNull()
// "boom"
bad.getErrorOrNull()?.message
// null
ok.getErrorOrNull()
// 42
ok.getOr(-1)
// -1
bad.getOr(-1)
// 42
ok.getOrElse { -1 }
// fallback computed from the error
bad.getOrElse { err -> err.message.length }
// 42
ok.getOrThrow()
// throws, built from status + error
bad.getOrThrow()
// the Err, since this is a Failure
bad.getErrorOrThrow()
// throws, since this is a Success
ok.getErrorOrThrow()
val ok2: Try<Int> = Success(42)
val bad2: Try<Int> = Failure(IllegalStateException("boom"))
// 42
ok2.getOrRethrow()
// rethrows the original IllegalStateException, unchanged
bad2.getOrRethrow()
Ops: Lists
Operators on List<Result<T, E>> for working with a batch of results at once: checking whether they all/any succeeded, sequencing them into one Result, or splitting them into separate success/error lists.
import kiit.result.Outcomes
import kiit.result.allFailure
import kiit.result.allSuccess
import kiit.result.anyFailure
import kiit.result.anySuccess
import kiit.result.combine
import kiit.result.partition
val results = listOf(Outcomes.success(1), Outcomes.success(2), Outcomes.success(3))
val mixed = listOf(Outcomes.success(1), Outcomes.unserved<Int>("boom"), Outcomes.success(3))
// true, every item succeeded
results.allSuccess()
// false, one Failure present
mixed.allSuccess()
// false
results.allFailure()
// false, not all failed either
mixed.allFailure()
// true
results.anySuccess()
// true, at least one succeeded
mixed.anySuccess()
// false
results.anyFailure()
// true
mixed.anyFailure()
// Success([1, 2, 3])
results.combine()
// Failure("boom"), short-circuits on the first Failure
mixed.combine()
// (listOf(1, 2, 3), emptyList())
results.partition()
// (listOf(1, 3), listOf(Err("boom")))
mixed.partition()
Ops: Transforms
Operators that reshape a Result into something else entirely: fold collapses both branches into one plain value, recover unconditionally turns a Failure into a Success, and flatten collapses a nested Result.
import kiit.codes.Err
import kiit.result.Outcome
import kiit.result.Outcomes
import kiit.result.Result
import kiit.result.Success
import kiit.result.flatten
import kiit.result.recover
val ok: Outcome<Int> = Outcomes.success(42)
val bad: Outcome<Int> = Outcomes.unserved("boom")
// "value: 42"
ok.fold({ "value: $it" }, { "error: ${it.message}" })
// "error: boom"
bad.fold({ "value: $it" }, { "error: ${it.message}" })
// unchanged Success, recover only touches Failure
ok.recover { -1 }
// Success(-1), Failure turned into Success
bad.recover { -1 }
val nested: Result<Result<Int, Err>, Err> = Success(Success(42))
// Success(42), one level unwrapped
nested.flatten()
Ops: Misc
The rest of the operator surface: branch-specific transforms and combinators (mapError, existsError, orElse, or, and), attaching a status or Action after construction (withStatus/withAction), and transform for mapping either branch into a brand-new Result.
import kiit.codes.Err
import kiit.codes.Restricted
import kiit.codes.Succeeded
import kiit.result.Action
import kiit.result.Outcome
import kiit.result.Outcomes
import kiit.result.Success
import kiit.result.and
import kiit.result.or
import kiit.result.orElse
val ok: Outcome<Int> = Outcomes.success(42)
val bad: Outcome<Int> = Outcomes.unserved("boom")
// unchanged Success, mapError only touches Failure
ok.mapError { Err.of("wrapped: ${it.message}") }
// Failure with a wrapped Err
bad.mapError { Err.of("wrapped: ${it.message}") }
// false, a Success never satisfies existsError
ok.existsError { it.message == "boom" }
// true
bad.existsError { it.message == "boom" }
// unchanged Success, orElse only touches Failure
ok.orElse { Success(-1) }
// Success(-1), recovered via a new Result
bad.orElse { Success(-1) }
// Success(42), or keeps the first Success
ok.or(Success(-1))
// Success(-1), or falls through to the fallback
bad.or(Success(-1))
// Success(-1), and swaps in the second Result when the first succeeds
ok.and(Success(-1))
// unchanged Failure, and short-circuits
bad.and(Success(-1))
// Success(42) with a new status
ok.withStatus(Succeeded.CREATED, Restricted.DENIED)
// Failure("boom") with a new status
bad.withStatus(Succeeded.CREATED, Restricted.DENIED)
// Success(42) tagged with an Action
ok.withAction(Action("chargeCard"))
// Success("value: 42")
ok.transform({ Success("value: $it") }, { Success("error: ${it.message}") })
// Success("error: boom")
bad.transform({ Success("value: $it") }, { Success("error: ${it.message}") })
Builders
Outcomes (and Options/Tries/Validations, one per alias) implement PassedBuilder<E>/FailedBuilder<E>, so every group below is called the same way regardless of which alias it's building. See Concepts > Builders for the full overload list and the default status each one applies.
- Prefer builders: Use
restricted()/invalid()/... (viaOutcomes/Options/Tries) for everyday construction. - Constructor is advanced: The raw
Success/Failureconstructor is a no-ceremony escape hatch, not the default path.
Passed group (success/pending/excluded/information), building a Success:
import kiit.codes.Succeeded
import kiit.result.Outcomes
// Success(42), default status Succeeded.SUCCESS
Outcomes.success(42)
// Success(42), custom message
Outcomes.success(42, "cache warm")
// Success(42), explicit status
Outcomes.success(42, Succeeded.CREATED)
// Success(0), a queued/not-yet-complete success
Outcomes.pending(0)
// Success(42), intentionally skipped, not a failure
Outcomes.excluded(42, "already processed")
// Success(Unit), an FYI-only outcome
Outcomes.information(Unit, "cache miss, refetched")
Failed group (restricted/invalid/rejected/unserved), building a Failure:
import kiit.codes.Err
import kiit.codes.Rejected
import kiit.codes.Restricted
import kiit.result.Outcomes
// Failure, default status Restricted.DENIED
Outcomes.restricted("not an admin")
// Failure, explicit status
Outcomes.restricted(Restricted.LOCKED)
// Failure, wrapping an existing Err
Outcomes.invalid(Err.on("email", "bad@", "must contain a domain"))
// Failure, wrapping a thrown exception
Outcomes.rejected(IllegalStateException("duplicate id"), Rejected.CONFLICT)
// Failure, default status Unserved.UNEXPECTED
Outcomes.unserved("downstream timed out")
An explicit status override (Restricted.LOCKED, Rejected.CONFLICT) is a classification decision, not something checked against the error's content, status and error are independent facts about the same Failure. See Relationship for why.
Options.some/Options.none are the Option<T>-specific pair on top of the same groups:
import kiit.result.Options
// Option<Int>, present
Options.some(42)
// Option<Int>, absent, Rejected.NOT_EXISTS
Options.none<Int>()
Alias: Outcome<T>
Outcome<T> = Result<T, Err> pairs a value with kiit-codes' Err on failure, the most commonly used alias. Outcomes is the ready-made Builder implementation for it. See the kiit-codes docs for Err's full shape (ErrorInfo/ErrorField/ErrorList).
- Intention:
Outcome<T>pairs a value with kiit-codes'Erron failure, the most commonly used alias. - Less boilerplate: Fixing the error type to
Errmeans callers don't have to define a custom error type forResult. - Ready-made builder:
OutcomesimplementsBuilder<Err>, so convenient builders come for free.
import kiit.codes.Err
import kiit.result.Outcome
import kiit.result.Outcomes
fun parseAge(input: String): Outcome<Int> {
val age = input.toIntOrNull()
return when {
age == null -> Outcomes.invalid(Err.on("age", input, "must be a number"))
age < 0 -> Outcomes.invalid(Err.on("age", input, "must not be negative"))
else -> Outcomes.success(age)
}
}
val ok: Outcome<Int> = parseAge("42")
val bad: Outcome<Int> = parseAge("nope")
// 42
ok.getOrNull()
// must be a number
bad.getErrorOrNull()?.message
Alias: Try<T>
Try<T> = Result<T, Throwable> uses an exception as the error type, for crossing an exception-only boundary. Tries.attempt catches a throwing computation and wraps whatever it throws.
- Intention:
Try<T>uses aThrowableas the error type, for compatibility with exception-based code. - Compatibility: Use this for interoperating with code that only uses exceptions, without a manual
try/catch. - Alternative: Replaces Kotlin's
kotlin.Result<T>, while addingstatusand kiit-result's operators.
import kiit.result.Try
import kiit.result.Tries
fun parseAge(input: String): Try<Int> = Tries.attempt { input.toInt() }
val ok: Try<Int> = parseAge("42")
val bad: Try<Int> = parseAge("nope")
// 42
ok.getOrNull()
// NumberFormatException: For input string: "nope"
bad.getErrorOrNull()
Alias: Option<T>
Option<T> = Result<T, Unit> reimagines the historical Option/Maybe role on Result, so absence carries a status explaining why instead of a bare None. Options.some/Options.none are the entry points.
- Intention:
Option<T>expresses presence of a value, not just success/failure. - Presence:
Success<T>means the value is present, a success of presence. - Absence:
Failure<Unit>means the value is absent, a failure of presence. - Adaptation: A deliberate adaptation of
Option/Maybe, not literal category-theory semantics. - Practicality: Familiar names, practical semantics, these aliases don't attempt to replicate category-theory abstractions.
import kiit.result.Option
import kiit.result.Options
fun findUser(id: String): Option<User> =
users[id]?.let { Options.some(it) } ?: Options.none()
val found: Option<User> = findUser("u1")
val missing: Option<User> = findUser("ghost")
// the User, if found
found.getOrNull()
// Rejected.NOT_EXISTS, the default "absent" status
missing.status
Alias: Validated<T>
Validated<T> = Result<T, Err.ErrorList> collects multiple errors instead of stopping at the first, for validating a whole form or request at once. Validations.of builds one from a value plus whatever errors were already found.
- Intention:
Validated<T>collects every error instead of stopping at the first, for validating a whole request at once. - Just an alias: Just a type alias on
Result<T, E>, not a separate applicative type. - Adaptation: Category-theory's
Validatedis typically its own accumulating-applicative abstraction, distinct fromEither. Here it's a practical specialization of the same monadicResult, not that abstraction.
import kiit.codes.Err
import kiit.result.Validated
import kiit.result.Validations
data class SignupForm(val email: String, val password: String)
fun validateSignup(form: SignupForm): Validated<SignupForm> {
val errors = mutableListOf<Err>()
if (form.email.isBlank()) errors.add(Err.on("email", form.email, "Email is required"))
if (form.password.length < 8) errors.add(Err.on("password", form.password, "Password is too short"))
return Validations.of(form, errors)
}
val result: Validated<SignupForm> = validateSignup(SignupForm("", "short"))
// 2, both fields failed
result.getErrorOrNull()?.errors?.size
Domain Errors
E doesn't have to be Err, a plain string, or an exception, it can be your own sealed domain error hierarchy, with each variant paired to its own kiit-codes status via a small HasStatus interface, instead of choosing one per call site. The same pattern applies to T when successful outcomes also need domain-specific variants. There are two approaches to combining the two:
- Approach 1, preferred: a separate sealed type per branch. The signature itself,
Result<CreateUserSuccess, CreateUserError>, tells a caller every value and every error an operation can produce, without reading its body. - Approach 2: one sealed type spanning both branches, deciding
SuccessorFailureat runtime from its own status. Useful when every outcome, passed or failed, genuinely belongs to one concept, at the cost of that signature-level clarity.
Wiring isn't automatic: success(value)/failure(error) read a domain type's own .status; the plain Success(value)/Failure(error) constructors still default status independently, see Relationship.
Approach 1: Preferred. Two sealed hierarchies, each carrying its own status:
import kiit.codes.Failed
import kiit.codes.HasStatus
import kiit.codes.Passed
import kiit.codes.Unserved
import kiit.result.Result
import kiit.result.failure
import kiit.result.success
// Succeeded codes
val USER_CREATED = Passed.Succeeded(name = "USER_CREATED", message = "User account created", origin = "users")
val QUEUED_FOR_REVIEW = Passed.Pending(name = "QUEUED_FOR_REVIEW", message = "Flagged for manual review", origin = "users")
// Failed codes
val EMAIL_TAKEN = Failed.Rejected(name = "EMAIL_TAKEN", message = "Email is already registered", origin = "users")
val INVALID_EMAIL = Failed.Invalid(name = "INVALID_EMAIL", message = "Email format is invalid", origin = "users")
val UNAUTHORIZED_CREATE = Failed.Restricted(name = "UNAUTHORIZED_CREATE", message = "Not authorized to create users", origin = "users")
// Sealed domain success: every way this operation can succeed
sealed class CreateUserSuccess(override val status: Passed) : HasStatus<Passed> {
data class Created(val email: String) : CreateUserSuccess(USER_CREATED)
data class QueuedForReview(val email: String) : CreateUserSuccess(QUEUED_FOR_REVIEW)
}
// Sealed domain error: every way this operation can fail
sealed class CreateUserError(override val status: Failed) : HasStatus<Failed> {
data class EmailTaken(val email: String) : CreateUserError(EMAIL_TAKEN)
data class InvalidEmail(val email: String) : CreateUserError(INVALID_EMAIL)
object Unauthorized : CreateUserError(UNAUTHORIZED_CREATE)
data class DatabaseUnavailable(val cause: Throwable) : CreateUserError(Unserved.UNEXPECTED)
}
fun createUser(
email: String,
isAuthorized: Boolean,
needsReview: Boolean,
): Result<CreateUserSuccess, CreateUserError> = when {
!isAuthorized -> failure(CreateUserError.Unauthorized)
!email.contains("@") -> failure(CreateUserError.InvalidEmail(email))
needsReview -> success(CreateUserSuccess.QueuedForReview(email))
else -> success(CreateUserSuccess.Created(email))
}
The signature alone answers "what can this return": a CreateUserSuccess variant or a CreateUserError variant, nothing else. success()/failure() read each variant's own .status, so createUser(...).status is always the right kiit-codes category for whichever variant came back.
Approach 2. When every outcome, passed or failed, genuinely belongs to one enumeration, a single sealed type can cover both branches, with build() inspecting its status at runtime instead of success()/failure() choosing a branch at compile time:
import kiit.codes.Failed
import kiit.codes.HasStatus
import kiit.codes.Passed
import kiit.codes.Status
import kiit.result.Result
import kiit.result.build
// Succeeded code
val ORDER_PLACED = Passed.Succeeded(name = "ORDER_PLACED", message = "Order placed", origin = "orders")
// Failed code
val ORDER_OUT_OF_STOCK = Failed.Rejected(name = "ORDER_OUT_OF_STOCK", message = "Item is out of stock", origin = "orders")
// Sealed domain result: spans both branches, status decides Success vs Failure at runtime
sealed class PlaceOrderResult(override val status: Status) : HasStatus<Status> {
data class Placed(val orderId: String) : PlaceOrderResult(ORDER_PLACED)
data class OutOfStock(val sku: String) : PlaceOrderResult(ORDER_OUT_OF_STOCK)
}
fun placeOrder(sku: String, inStock: Boolean): Result<PlaceOrderResult, PlaceOrderResult> =
build(if (inStock) PlaceOrderResult.Placed(orderId = "ord-1") else PlaceOrderResult.OutOfStock(sku))
- Loses signature clarity:
Result<T, T>doesn't tell what success or failure look like, both branches the same type. - Easy to misuse: A
Placedvariant can be on theFailureside, or vice versa, ifbuild()is bypassed. - Prefer separate types: Use this combined type when every outcome, passed/failed, is one concept, not by default.
Swift Interop
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.
FAQ
Why
| Question | Answer |
|---|---|
Why not just use Arrow's Either/Validated or kotlin-result? | Those give a monad with zero built-in taxonomy. The meaning is supplied by each team itself. kiit-result is the same kind of monad fused to kiit-codes' taxonomy, for 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. |
Doesn't status need to match error, semantically? | No, they answer different questions about the same Failure: status is what kind of outcome this is, error is what happened. Neither is derived from the other, the same way an HTTP response's status code isn't derived from its body text. See Relationship for the full reasoning. |
Why two ways to build a value (constructor vs. Builder<E>) instead of one? | Builder<E> (via Outcomes/Options/Tries) is the preferred, everyday path. The plain constructor is a no-ceremony escape hatch for when no Builder is in scope, an advanced path, not the default. |
Alternatives
| Question | Answer |
|---|---|
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 adaptation: the same historical role as Rust/Scala/Arrow's Option, reimagined so absence carries a status explaining why instead of a bare None. Options.some(value)/Options.none() make that explicit. |
| Does this replace exceptions? | No. Result<T, E> is for expected, application-level outcomes; exceptions still belong at exception boundaries and for genuinely exceptional conditions. Try<T> and the conversions (toTry()/Tries.of) exist to interoperate between the two, not replace one with the other. See Tutorial > Add the Alias. |
Does Result support coroutines? | Yes, for sequential composition: Result<T, E> is just a return type, so any suspend fun can return one, and since map/flatMap/fold/etc. are all inline, a suspend call works fine inside their lambdas too, no special handling needed. Coroutine-specific concurrent composition and cancellation-safety machinery, running steps in parallel and cancelling siblings on failure, is a different problem, deliberately out of scope, see the Coroutine module exclusion. |
API
| Question | Answer |
|---|---|
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 does excluded() build a Success, not a Failure? | Excluded is a Passed group in kiit-codes. An intentionally skipped, deduplicated, or 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 group's constants on its own companion rather than one shared object. |
What if I don't care about Status? | You don't have to manage it. Builders apply a sensible default when none is supplied (Outcomes.rejected("duplicate") defaults to Rejected.RULE_VIOLATION), so routine use never requires touching Status directly, the same gradual-adoption path as Philosophy's Start Simple. |
Adoption
| Question | Answer |
|---|---|
| Can I use my own error type and ignore kiit-codes? | Only partially. E is generic (use Throwable, String, a domain 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. |
How do I turn a Result into an HTTP/gRPC response? | Result doesn't own transport mapping, that's kiit-codes' job. Map result.status through CodesToHttp/CodesToGrpc at the boundary where you actually need a status code, keeping the two concerns separate. See Responses (HTTP/gRPC) and the kiit-codes docs. |
| Does this actually work on JS and iOS today? | kiit-result's production history 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 (@JsExported, not covered by CI or published to npm, since TypeScript can't compiler-enforce exhaustiveness). iOS uses SKIE for real, compiler-enforced Swift exhaustiveness, a materially better story than JS, including plain Kotlin objects (Outcomes/Options/Tries) getting clean .shared access with no extra work. |
AI
| Question | Answer |
|---|---|
| Is the "built for AI" angle just marketing? | Same answer 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, specific to kiit-result rather than any Result type? | Exhaustive Success/Failure matching is common to any closed Result type, Rust and kotlin-result already give a model that. What's specific here is the shared status vocabulary on both branches, not just Failure: a model reading or generating code has one small, named set of categories to reach for either way, instead of ad hoc exception types on one side and nothing on the other. That said, this only covers the mechanical part, that a branch or a status subtype can't be silently missed. Which category actually fits a given error is still a judgment call nothing in the type system, or a model, can verify. See Relationship for why that's by design, not a gap this could close. |
Maturity
| Question | Answer |
|---|---|
| Is this production-ready at 1.0.1? | The 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 mobile and server applications inside the original Kiit toolkit. What's actually new: extraction into an independent repo, an updated and polished taxonomy in kiit-codes, and kiit-codes/kiit-result now being fully decoupled from each other. The multiplatform export work is the one piece still genuinely in progress. |
| What about single-maintainer risk? | Real, worth being upfront about: Apache 2.0, source available, no second maintainer or organizational backing yet. |