Skip to main content

kiit-result logokiit-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.

Kiit Result overview

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

SourceWhat it contributes
ScalaEither/Try, precedent for a flexible-error-type Result distinct from a bare Option
RustResult<T, E>'s two-branch monadic shape, and most of the operators (map, and_then, unwrap)
kotlin-resultKotlin-idiomatic naming for that same operator set (getOr, recover, combine, partition)
kiit-codesThe 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

#ResourceLink
1Repositorygithub.com/kiitdev/kiit-result
2Maven Centraldev.kiit:kiit-result
3npmNot yet published. JS/TS is a partial pass not covered by CI, see Limitations
4Related modulekiit-codes, the status taxonomy this library builds on
5Samplessamples/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

#WhatLinks
1Sourcekiit-result/src/commonMain
2Packagekiit.result
3Samplessamples/
4Testskiit-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

TermWhat it is
Result<T, E>Sealed type, either Success<T> or Failure<E>More
Success<T>Holds a value: T and a status: PassedMore
Failure<E>Holds an error: E and a status: FailedMore
ActionOptional context for the operation that produced/wrapped a ResultMore
AliasesOption<T>/Try<T>/Outcome<T>/Validated<T>, type aliases fixing E for common casesMore
Operatorsmap/flatMap/fold/recover/..., the composition surfaceMore
BuildersStatus-aware factory methods for Success/FailureMore
ConversionstoOutcome()/toTry(), crossing between error-type shapesMore
Statuskiit-codes' Passed/Failed taxonomy, attached to every ResultMore
Start Simple
  1. Start here: Adopt Result<T, E> on its own first, the rest of the table above is optional.
  2. Adopt gradually: Add Status, Aliases, Builders, or Action only 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:

  1. Success: holds a value: T, defaults its status to Succeeded.SUCCESS.
  2. Failure: holds an error: E, defaults its status to Unserved.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.

Kiit Result structure

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
PassedSucceeded, Pending, Excluded, Information
FailedRestricted, Invalid, Rejected, Unserved
Status
  1. Not "done": Success<T> means a Passed status, not "the operation completed"
  2. Still a success: Pending (queued) or Excluded (intentionally skipped) are a Success, not a lesser kind of failure.
  3. Independent facts: status and error are independent fields of Failure, and don't have to agree. See Relationship.
  4. 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

Kiit Result status taxonomy

Action

Action names the operation that produced or wrapped a Result. It carries:

  1. action: String: the operation name, required.
  2. xid: String?: an optional correlation id for context.
  3. data: Map<String, String>: optional free-form attributes.
  4. 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.

Action
  1. Scope: Action is a lightweight context bag (name, correlation id, attributes, previous link) by design.
  2. Not observability: This is not meant to replace tracing/telemetry systems (e.g. OpenTelemetry).
  3. Use case: Most useful for debugging or response structures, showing the operational context associated with a result.

Kiit Result action

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.

Kiit Result aliases

  1. Outcome: kiit-codes' Err as the error type, the most commonly used alias.
  2. Validated: For validation, collecting multiple errors instead of stopping at the first.
  3. Try: Exception as the error type, the shape used when crossing an exception-only boundary.
  4. Option: The historical Option/Maybe role (Rust/Scala/Arrow), reimagined so absence carries a status explaining why, not just a bare None. Options.some(value)/Options.none() are the entry points.
Validated<T>
  1. Intention: Validated<T> collects every error instead of stopping at the first, for validating a whole request at once.
  2. Just an alias: Just a type alias on Result<T, E>, not a separate applicative type.
  3. Adaptation: Category-theory's Validated is typically its own accumulating-applicative abstraction, distinct from Either. Here it's a practical specialization of the same monadic Result, not that abstraction.
Option<T>
  1. Intention: Option<T> expresses presence of a value, not just success/failure.
  2. Presence: Success<T> means the value is present, a success of presence.
  3. Absence: Failure<Unit> means the value is absent, a failure of presence.
  4. Adaptation: A deliberate adaptation of Option/Maybe, not literal category-theory semantics.
  5. 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:

OperatorPurpose
map, mapErrorTransform the success value or error, leaving the other branch untouched
flatMap/then, orElseChain another Result-returning step on the success or failure branch
fold, transformCollapse both branches to one value (fold), or to a new Result from either branch (transform)
exists, existsErrorPredicate check against the success value or the error, without unwrapping
getOrNull, getErrorOrNullNullable accessors for the value or the error
getOrElse, getOrValue or a computed (getOrElse) / literal (getOr) fallback
getOrThrow, getErrorOrThrowValue/error or throw, with an optional custom message
getOrRethrowValue, or rethrow the original error unchanged (only when E : Throwable)
onSuccess, onFailureSide-effect on one branch, Result unchanged either way
recoverUnconditionally turn a Failure into a Success
flattenFlatten a nested Result<Result<T, E>, E>
or, andCombine with a second Result, independent of any transform
withStatus, withActionAttach 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.

Builders
  1. Prefer builders: Use restricted()/invalid()/... (via Outcomes/Options/Tries) for everyday construction.
  2. Constructor is advanced: The raw Success/Failure constructor is a no-ceremony escape hatch, not the default path.
BuilderStatus groupDefault Code
success(value)Passed.SucceededSucceeded.SUCCESS
pending(value)Passed.PendingPending.ACCEPTED
excluded(value)Passed.ExcludedExcluded.OMITTED
information(value)Passed.InformationInformation.NOTICE
restricted(...)Failed.RestrictedRestricted.DENIED
invalid(...)Failed.InvalidInvalid.INVALID_VALUE
rejected(...)Failed.RejectedRejected.RULE_VIOLATION
unserved(...)Failed.UnservedUnserved.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 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.

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.

#IdeaDescription
1StatusMost 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.
2BuildersBuilders 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.
3AliasesE 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.
4ActionAn 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.
5Scopekiit-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.

Start Simple
  1. Status first: Result<T, E> with Status alone covers most needs, everything below is additive.
  2. Builders next: Reach for Outcomes/Options/Tries once constructing values by hand gets repetitive.
  3. Then as needed: Aliases and Action are 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:

  1. 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.
  2. 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:

  1. ErrorInfo: a message with an optional cause, the general-purpose default.
  2. ErrorField: a message tied to a specific field/value, for validation-style errors.
  3. ErrorList: wraps multiple Errs into one, what Validated<T> collects into.

Every Err carries a message: String, a cause: Throwable?, and a ref: Any? for attaching arbitrary context.

BuilderProduces
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-resultkotlin-resultSwiftRust
mapmapmapmap
mapErrormapErrormapErrormap_err
flatMap/thenflatMap/andThenflatMapand_then
orElseorElseflatMapErroror_else
ororor
andandand
foldfold— (use switch)map_or_else
transformflatMapEither
existsis_ok_and
existsErroris_err_and
onSuccess/onFailureonOk/onErrinspect/inspect_err
getOrNull/getErrorOrNullget/getErrorok()/err()
getOrElsegetOrElseunwrap_or_else
getOrgetOrunwrap_or
getOrThrow()unwrap()get() throwsunwrap()
getOrThrow(message)expect(message)expect(msg)
getErrorOrThrow()unwrapError()unwrap_err()
getErrorOrThrow(message)expectError(message)expect_err(msg)
getOrRethrowgetOrThrow() (E : Throwable)— (no exceptions)
recoverrecover
flattenflattenflatten (since 1.89.0)
combinecombinevia collect::<Result<Vec<T>, E>>()
partitionpartition
allSuccess/allFailureallOk/allErr
anySuccess/anyFailureanyOk/anyErr
Outcomes/Tries.attemptrunCatchinginit(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

#FeatureDescription
1Status on both branchesEvery Success/Failure carries a status, not just failure. See Structure
2Flexible error typeE can be String, Throwable, Err, or a domain type. See Aliases
3Operation contextOptional Action records the operation across nested calls. See Action
4Status-aware buildersrestricted/invalid/... prepopulate the matching status. See Builders
5Exception-boundary conversionstoTry()/Tries.of cross into and out of exceptions. See Conversions
6List-combining operatorscombine/partition/... work on a batch of Results. See Operators

Limitations

#LimitationDetails
1Swift distribution unbuiltNot yet distributed via SPM/XCFramework. See Swift Interop
2AI-angle claims unprovenBetter accuracy/searchability from a closed vocabulary is the claimed benefit, not something measured
3JS/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

#ExcludedReasoning
1Coroutine moduleCancellation-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
2zip/tryMap-style iterable mirrorsLarge in volume, buildable from combine if actually needed. Not worth the surface area yet
3Numeric status codeDropped, 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

  1. Service layers: return Outcome<T> instead of throwing for expected failures.
  2. Pipelines: map/flatMap chains compose without manual null/exception checks at each step.
  3. Validation: Validated<T> (Result<T, Err.ErrorList>) collects multiple errors via Validations.
  4. Exception boundaries: toTry()/Tries.of interop with StatusException when a caller only understands exceptions.
  5. HTTP/gRPC responses: result.status converts via kiit-codes' CodesToHttp/CodesToGrpc. See Responses (HTTP/gRPC).

Good fit if:

  1. Explicit, monadic return values instead of throw/catch for expected failures are wanted.
  2. kiit-codes' status taxonomy is already in use (or wanted), with a Result type layered on top instead of a bespoke one.
  3. Several fallible steps (map/flatMap) need composing without nested try/catch.

Probably not necessary if:

  1. Exceptions already communicate everything needed, and the monadic-return-value style isn't wanted.
  2. Only status classification is needed, not a Result wrapper. 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.

Status
  1. Not "done": Success<T> means a Passed status, not "the operation completed"
  2. Still a success: Pending (queued) or Excluded (intentionally skipped) are a Success, not a lesser kind of failure.
  3. Independent facts: status and error are independent fields of Failure, and don't have to agree. See Relationship.
  4. 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.

Action
  1. Scope: Action is a lightweight context bag (name, correlation id, attributes, previous link) by design.
  2. Not observability: This is not meant to replace tracing/telemetry systems (e.g. OpenTelemetry).
  3. 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.

Builders
  1. Prefer builders: Use restricted()/invalid()/... (via Outcomes/Options/Tries) for everyday construction.
  2. Constructor is advanced: The raw Success/Failure constructor 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).

Outcome<T>
  1. Intention: Outcome<T> pairs a value with kiit-codes' Err on failure, the most commonly used alias.
  2. Less boilerplate: Fixing the error type to Err means callers don't have to define a custom error type for Result.
  3. Ready-made builder: Outcomes implements Builder<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.

Try<T>
  1. Intention: Try<T> uses a Throwable as the error type, for compatibility with exception-based code.
  2. Compatibility: Use this for interoperating with code that only uses exceptions, without a manual try/catch.
  3. Alternative: Replaces Kotlin's kotlin.Result<T>, while adding status and 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.

Option<T>
  1. Intention: Option<T> expresses presence of a value, not just success/failure.
  2. Presence: Success<T> means the value is present, a success of presence.
  3. Absence: Failure<Unit> means the value is absent, a failure of presence.
  4. Adaptation: A deliberate adaptation of Option/Maybe, not literal category-theory semantics.
  5. 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.

Validated<T>
  1. Intention: Validated<T> collects every error instead of stopping at the first, for validating a whole request at once.
  2. Just an alias: Just a type alias on Result<T, E>, not a separate applicative type.
  3. Adaptation: Category-theory's Validated is typically its own accumulating-applicative abstraction, distinct from Either. Here it's a practical specialization of the same monadic Result, 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:

  1. 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.
  2. Approach 2: one sealed type spanning both branches, deciding Success or Failure at 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.
HasStatus

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))
Combined Type
  1. Loses signature clarity: Result<T, T> doesn't tell what success or failure look like, both branches the same type.
  2. Easy to misuse: A Placed variant can be on the Failure side, or vice versa, if build() is bypassed.
  3. 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

QuestionAnswer
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

QuestionAnswer
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

QuestionAnswer
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

QuestionAnswer
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

QuestionAnswer
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

QuestionAnswer
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.