kiit-codes
A Kotlin library for classifying and handling success and failure.
A small, dependency-free status and error taxonomy for application outcomes, with
extensible codes, protocol mappings, validation, typed exceptions, and optional
Result<T, E> integration.
- Familiar Concept: Like HTTP status codes, but generalized to name the kind of outcome (success, not found, etc.).
- For any layer: Can be used at any technical layer: services, jobs, validation, exceptions, APIs.
- Usable anywhere: Maps to HTTP and gRPC, and you can add your own domain codes.

Overview
Goals
Applications need to communicate a simple idea consistently: what happened? In practice, success and failure get modeled differently across domains, layers, and protocols, which causes recurring problems: no shared taxonomy for classifying outcomes, inconsistent handling across layers, validation/exceptions/statuses/results all using different approaches, similar error types rebuilt project to project, and generic errors that lose domain-specific meaning.
kiit-codes exists to provide a shared, application-level model for these concerns: a fixed taxonomy for consistent classification, extensible codes that preserve domain-specific meaning, and protocol mappings that keep application outcomes independent from how they're transported. The same model is then reused across statuses, validation, exceptions, and result types.
The taxonomy is closed at the top and open underneath. A fixed set of groups keeps generic handling, exhaustive matching, logging, and protocol mappings consistent everywhere a status is used, and codes stay open so each domain can add its own without forking the taxonomy. Codes are ordinary values rather than enum cases, which is what lets a domain add them. This doesn't replace domain modeling: a domain error explains what happened in one domain, and the taxonomy explains what kind of outcome it was, consistently, across every domain in an application.
Features
| # | Feature | Description |
|---|---|---|
| 1 | Status classification | The core Passed/Failed split, with a fixed Group and an open Code beneath it for finer-grained classification. |
| 2 | Extensibility | Add domain-specific codes within the same fixed groups, without forking the taxonomy or losing shared meaning. |
| 3 | Protocol mappings | Map statuses to HTTP, gRPC, or any custom protocol via CodeLookup/CompositeLookup. |
| 4 | Validation | Checked, Err, and collect report every problem found at once, instead of stopping at the first. |
| 5 | Typed exceptions | StatusException and Failed.toException() for boundaries that only understand exceptions. |
| 6 | Result integration | The separate kiit-result module builds a Result<T, E> type on top of this same taxonomy. |
Inspiration
| # | Source | What was drawn from it |
|---|---|---|
| 1 | HTTP status codes | Validated against, not derived from — the most common HTTP codes map onto kiit-codes' eight groups without needing a ninth. |
| 2 | gRPC status codes | Same validation as HTTP — every gRPC code maps onto the existing eight groups. |
| 3 | Scala's Either/Try | Built on Either's two-branch shape, structured around Try's branches, with the Status taxonomy incorporated on top. |
Activity
The core classification model has years of internal production use inside the original Kiit framework, powering both mobile and server Kotlin applications, prior to being extracted into this standalone repository. The public package version reflects the standalone repo's youth, not the underlying design's: the classification itself is settled, while newer additions (JS/TS export, iOS/Swift export via SKIE) have less track record and are still being exercised.
Resources
| # | Resource | Details |
|---|---|---|
| 1 | Repository | github.com/kiitdev/kiit-codes |
| 2 | Maven coordinate | dev.kiit:kiit-codes |
| 3 | npm coordinate | @kiit/codes (JS/TS export, not CI-gated yet) |
| 4 | Related module | kiit-result builds a Result<T, E> type on top of this same taxonomy (docs page coming next) |
| 5 | API reference | Generated from source KDoc, linked here once published |
Prefer to see it work first? Jump to the Tutorial. Prefer the reasoning first? Keep reading.
Setup
Install
- Kotlin
- Java
- TypeScript
dependencies {
implementation("dev.kiit:kiit-codes:1.1.0")
}
<dependency>
<groupId>dev.kiit</groupId>
<artifactId>kiit-codes</artifactId>
<version>1.1.0</version>
</dependency>
dependencies {
implementation 'dev.kiit:kiit-codes:1.1.0'
}
npm install @kiitdev/codes
| # | Language | Artifact |
|---|---|---|
| 1 | Kotlin, Java | Maven Central |
| 2 | TypeScript | npm |
| 3 | Swift | Swift Package Manager, link to come |
Imports
What to import to use the library.
- Kotlin
- TypeScript
import kiit.codes.*
import kiit.codes.formats.*
import { Succeeded, Restricted } from "@kiitdev/codes";
import type { Status } from "@kiitdev/codes";
Source
| # | Item | Link |
|---|---|---|
| 1 | Git Repo | github.com/kiitdev/kiit-codes |
| 2 | Root folder of sources in repo | kiit-codes-kotlin/kiit-codes/src/commonMain/kotlin |
| 3 | Sample app | samples/sample-kotlin |
| 4 | Package Name | kiit.codes |
| 5 | Unit Tests | kiit-codes-kotlin/kiit-codes/src/commonTest |
Licensed Apache 2.0.
Example
- Kotlin
- Java
- TypeScript
- Swift
import kiit.codes.*
fun authorize(userId: String, requesterId: String): Status =
if (userId != requesterId) Restricted.UNAUTHORIZED
else Succeeded.SUCCESS
when (val status = authorize(userId, requesterId)) {
is Passed -> log.info("ok: ${status.name}")
is Failed -> log.warn("failed: ${status.name} — ${status.message}")
}
import kiit.codes.Status;
import kiit.codes.Passed;
import kiit.codes.Failed;
static Status authorize(String userId, String requesterId) {
return !userId.equals(requesterId) ? Failed.Restricted.UNAUTHORIZED : Passed.Succeeded.SUCCESS;
}
Status status = authorize(userId, requesterId);
switch (status) {
case Passed p -> log.info("ok: " + p.getName());
case Failed f -> log.warn("failed: " + f.getName() + " — " + f.getMessage());
}
import { Succeeded, Restricted } from "@kiitdev/codes";
import type { Status } from "@kiitdev/codes";
function authorize(userId: string, requesterId: string): Status {
return userId !== requesterId ? Restricted.UNAUTHORIZED : Succeeded.SUCCESS;
}
const status = authorize(userId, requesterId);
if (status.success) {
log.info(`ok: ${status.name}`);
} else {
log.warn(`failed: ${status.name} — ${status.message}`);
}
import KiitCodes
func authorize(_ userId: String, _ requesterId: String) -> Status {
userId != requesterId ? Failed.Restricted.companion.UNAUTHORIZED : Passed.Succeeded.companion.SUCCESS
}
let status = authorize(userId, requesterId)
switch onEnum(of: status) {
case .passed(let passed):
print("ok: \(passed.name)")
case .failed(let failed):
print("failed: \(failed.name) — \(failed.message)")
}
Explanation
Terms
| # | Term | Definition | |
|---|---|---|---|
| 1 | Taxonomy | The overall Status → Group → Code classification system. | More |
| 2 | Status | Sealed interface for an operation's outcome: Passed or Failed. | More |
| 3 | Group | Second tier: a fixed subtype of Passed/Failed (e.g. Restricted). | More |
| 4 | Code | Third tier: an open Status instance within a group (e.g. DENIED). | More |
| 5 | Err | Error representation for use with Result/Outcome-style types. | More |
| 6 | Checked | Non-monadic validation result reporting every problem, not just the first. | More |
| 7 | StatusException | Sealed exception hierarchy carrying a Checked, for exception-only boundaries. | More |
Status
A Status is the outcome of any operation, at any layer: a service call, a background job step, an
API request, a CLI command. It says what kind of success or failure happened, in one shape
everywhere, and nothing about the details of this one occurrence. Those belong to an Err.
Built-in codes and your own sit side by side, in the same groups:

Every Status belongs to exactly one group, and each concrete Status is a code within that group.
Invalid.INVALID_VALUE is the INVALID_VALUE code in the Invalid group, under Failed. This is how
that built-in code is defined:
- Kotlin
- Java
- TypeScript
val INVALID_VALUE = Invalid(
name = "INVALID_VALUE",
message = "The request had an invalid value.",
origin = StatusConstants.KIIT,
)
Failed.Invalid INVALID_VALUE = new Failed.Invalid(
"INVALID_VALUE",
"The request had an invalid value.",
StatusConstants.KIIT,
"");
const INVALID_VALUE: Invalid = Invalid(
"INVALID_VALUE",
"The request had an invalid value.",
StatusConstants.KIIT,
);
And this is the same Status as it appears in an API response:
{
"success": false,
"name" : "INVALID_VALUE",
"group" : "Invalid",
"origin" : "kiit.dev",
"scope" : "",
"message": "The request had an invalid value."
}
Every Status carries the same six fields, built-in or custom:
| Field | Why it exists |
|---|---|
| success | true for Passed, false for Failed. A quick check that doesn't need the group. |
| name | A stable, SCREAMING_SNAKE_CASE key that logs, metrics and clients can match on. Never built from runtime data. |
| group | The kind of outcome (Succeeded, Invalid, ...). Lets generic code handle any Status, including custom ones, without knowing its name. |
| origin | Who owns the code: kiit.dev for built-ins, your domain or a name of your own for custom codes. Keeps custom codes apart from the built-in ones and other teams', and becomes the host of an RFC 9457 type. |
| scope | An optional label inside an origin, such as a department or product area (payments.cards). Empty when unset. Never parsed. |
| message | A constant description of the code, not of one occurrence. Per-occurrence detail lives in an Err. |
- Domain: A domain you own is unique through DNS, so your codes can't collide with anyone else's.
- Plain id: A plain id such as
myapp1can collide with another team's, and kiit-codes can't detect it. Pick a specific name. - Also a host: The origin becomes the host of the RFC 9457
type, for examplehttps://samples.kiit.dev/problems/....
Taxonomy
The Status → Group → Code taxonomy: the two Status branches, the eight groups, and the codes
within them.

| Tier | Parent | Fixed/Open | Children | Description |
|---|---|---|---|---|
| 1 | Status | Fixed | Passed | |
| Failed | ||||
| 2 | Group | Fixed | Succeeded | The operation completed successfully. |
| Pending | The operation was accepted but has not yet fully resolved. | |||
| Excluded | The item was intentionally excluded from the operation. | |||
| Information | The response provides information; no operation was performed. | |||
| Restricted | The caller is not allowed. | |||
| Invalid | The request itself is wrong. | |||
| Rejected | The caller was allowed, but the business refuses it. | |||
| Unserved | The system can't serve it right now, though nothing was wrong with the request. | |||
| 3 | Code | Open + Defaults | Ships with common built-in codes (e.g. SUCCESS, DENIED); extensible with custom, domain-specific codes within the same group. |
Err
Error representation for use with Validation, Exceptions, and Result types. This stores instance level error details and the building block for Checked's error list.
- Status: The kind of outcome, constant:
Invalid.INVALID_VALUE. - Err: The details of this occurrence: which field, what value, what message.
- Together:
Checkedcarries both, so a status and its errors travel together.
Checked
Non-monadic validation result that reports every problem at once, instead of stopping at the
first. Checked(status: Status, errors: List<Err>), reachable only through
Checked.success(status) or Checked.failure(status, errors).
class Checked private constructor(
val status: Status,
val errors: List<Err>,
) : HasErrors {
val isValid: Boolean get() = errors.isEmpty()
companion object {
fun success(status: Passed = Succeeded.SUCCESS): Checked
fun failure(status: Failed, errors: List<Err>): Checked
}
}
| # | Trait | Details |
|---|---|---|
| 1 | Invariant | status and errors can never disagree: a passing Checked always has an empty errors list, a failing one always has at least one entry. |
| 2 | isValid | Boolean, reflects errors.isEmpty(). |
| 3 | Interface | Implements HasErrors. |
| 4 | collect(...) | collect(vararg checks) / collect(checks: List<Checked>) combine multiple Checked into one, failing with Invalid.INVALID_VALUE and every pooled error if any input failed. |
Exceptions
Sealed exception hierarchy carrying a Checked, for boundaries that only understand
exceptions — one subclass per Failed group:
sealed class StatusException(val checked: Checked) : Exception() {
val status: Status get() = checked.status
val errors: List<Err> get() = checked.errors
class RestrictedException(status: Failed.Restricted, errors: List<Err> = emptyList()) : StatusException(...)
class InvalidException(status: Failed.Invalid, errors: List<Err> = emptyList()) : StatusException(...)
class RejectedException(status: Failed.Rejected, errors: List<Err> = emptyList()) : StatusException(...)
class UnservedException(status: Failed.Unserved, errors: List<Err> = emptyList()) : StatusException(...)
}
| Exception | Matches |
|---|---|
RestrictedException | Failed.Restricted |
InvalidException | Failed.Invalid |
RejectedException | Failed.Rejected |
UnservedException | Failed.Unserved |
| # | Trait | Details |
|---|---|---|
| 1 | Carries | A Checked, exposed as status: Status and errors: List<Err>. |
| 2 | Conversion | Failed.toException(errors) converts a bare Failed status into the matching subclass. |
| 3 | Platform equivalents | iOS via @ObjCName in iosMain; JS/TS via jsMain. |
Protocols
Maps a Status to an external protocol's code: HTTP and gRPC out of the box, or a custom protocol of
your own via CodeLookup.
| Type | Purpose |
|---|---|
CodesToHttp | Maps Status to HTTP status codes. |
CodesToGrpc | Maps Status to gRPC status codes. |
CodeLookup | Interface for defining a mapping to any other protocol. |
CompositeLookup | Combines a base CodeLookup with per-code extensions/overrides. |
- No reverse conversion: There is no way to get a
Statusback from an HTTP or gRPC code, because many statuses share one code. - Carry the status instead: To send a status across a boundary, use the
codeof aCodeDetail, or the RFC 9457type.

Limitations
What kiit-codes doesn't do, and why.
| # | Limitation | Details |
|---|---|---|
| 1 | AI framing is unproven | Stable names and explicit classification are expected to reduce ambiguity for AI tooling, but that's a hypothesis, not a benchmarked result. |
| 2 | No retry logic or severity levels | Retryability cuts across groups rather than aligning with them — Unserved alone has both retryable and non-retryable codes. A dedicated Retry category was considered and rejected for the same reason. |
| 3 | No numeric status code field | An earlier version had one; it invited the wrong inference (looking like an HTTP code while meaning something else). Real protocol numbers are available on demand via CodesToHttp/CodesToGrpc, never implied by the taxonomy itself. |
| 4 | No ninth group | Every gRPC code and the most common HTTP codes map onto the existing eight without needing one, tested directly against both. |
Tutorial
Status Codes
This walks through building a tiny service that returns Status for expected outcomes, then
crosses a boundary that can only communicate via exceptions.
- Start here: Return a
Statusfrom your functions first. - Add as needed: Bring in
Checkedfor validation and exceptions for boundaries only when you need them.
Define a service that returns a Status instead of throwing for expected failures:
import kiit.codes.*
data class User(val id: String, val email: String)
class UserService {
private val users = mutableMapOf<String, User>()
fun create(id: String, email: String): Status {
if (email.isBlank()) return Invalid.BAD_REQUEST
if (users.containsKey(id)) return Rejected.CONFLICT
users[id] = User(id, email)
return Succeeded.CREATED
}
fun authorize(id: String, requesterId: String): Status =
when {
!users.containsKey(id) -> Rejected.NOT_EXISTS
id != requesterId -> Restricted.UNAUTHORIZED
else -> Succeeded.SUCCESS
}
}
Callers
Call it and branch on the result:
val service = UserService()
val created = service.create("alice", "alice@example.com")
println("${created.name} (success=${created.success})") // CREATED (success=true)
val denied = service.authorize("alice", "bob")
println("${denied.name} (success=${denied.success})") // UNAUTHORIZED (success=false)
Validation
Now add a method that reports every problem at once instead of stopping at the first:
fun UserService.validateSignup(id: String, email: String): Checked {
val errors = mutableListOf<Err>()
if (id.isBlank()) errors.add(Err.on("id", id, "Id is required"))
if (!email.contains("@")) errors.add(Err.on("email", email, "Email must contain @"))
return if (errors.isEmpty()) Checked.success(Succeeded.SUCCESS)
else Checked.failure(Invalid.INVALID_VALUE, errors)
}
val checked = service.validateSignup("", "not-an-email")
println("valid=${checked.isValid}, errors=${checked.errors.size}")
// valid=false, errors=2
Checked can only be constructed through Checked.success(status)/Checked.failure(status, errors),
so status and errors can never disagree. See Explanation for the full type, or
Guide for collect(...) combining multiple Checked results into one.
Try/Catch
Now add a method that throws instead, for a caller that only understands exceptions:
fun UserService.requireAuthorized(id: String, requesterId: String) {
val status = authorize(id, requesterId)
if (status is Failed) throw status.toException()
}
try {
service.requireAuthorized("alice", "bob")
} catch (e: StatusException) {
println("caught: ${e.status.name} — ${e.message}")
// caught: UNAUTHORIZED — Not authorized to perform this action
}
status.toException() picked StatusException.RestrictedException automatically, since
Restricted.UNAUTHORIZED belongs to the Restricted group. See Explanation
for the full exception hierarchy, or Goals for why the taxonomy is shaped this
way.
Result
kiit-codes classifies an outcome, but doesn't hand back a value alongside it. For that, pair it
with kiit-result — a separate Kiit library that builds a Result<T, E> type
on this same taxonomy:
fun UserService.find(id: String): Result<User, Status> =
users[id]?.let { Result.success(it) } ?: Result.failure(Rejected.NOT_EXISTS)
See the kiit-result docs for the full API.
Guide
Usage
| # | Area | Topic | Task | |
|---|---|---|---|---|
| 1 | Statuses | Return a status | Return a Status instead of throwing | More |
| 2 | Branch on a status | Check success, Passed/Failed, or a group with when | More | |
| 3 | Define custom codes | Add codes with a name, message, origin and scope | More | |
| 4 | Test a status | Assert on a status or isDefault in a test | More | |
| 5 | Errors | Attach error details | Carry Err info with a status | More |
| 6 | Validate and collect errors | Report every problem with Checked and collect() | More | |
| 7 | Throw and catch | Cross an exception-only boundary with toException() | More | |
| 8 | Protocols | Map to HTTP | Convert a status to an HTTP code | More |
| 9 | Map to gRPC | Convert a status to a gRPC code | More | |
| 10 | Problem details | Return Problem details | Convert a status to an RFC 9457 Problem, with errors and scope | More |
| 11 | Set the type URL | Domain origin, plain id, or registered base URLs | More | |
| 12 | Serialize to JSON | Write a Problem with Jackson | More |
Status only, when the outcome itself is enough:
when (val status = authorize(userId, requesterId)) {
is Passed -> log.info("ok: ${status.name}")
is Failed -> log.warn("failed: ${status.name} — ${status.message}")
}
Extensibility — custom codes stay inside a built-in group:
val PAYMENT_DECLINED = Failed.Rejected(
name = "PAYMENT_DECLINED",
message = "Payment declined",
origin = "payments",
)
PAYMENT_DECLINED remains a Rejected outcome everywhere in the system while retaining its own
domain-specific identity. origin keeps custom namespaces distinct from "kiit" and from other
teams' codes.
Validation, reporting every problem instead of stopping at the first:
fun validateUser(name: String, email: String): Checked {
val errors = mutableListOf<Err>()
if (name.isBlank()) errors.add(Err.on("name", name, "Name is required"))
if (!email.contains("@")) errors.add(Err.on("email", email, "Email must contain @"))
return if (errors.isEmpty()) Checked.success() else Checked.failure(Invalid.INVALID_VALUE, errors)
}
Exceptions, converting a Failed status at a boundary that needs one:
fun requireAuthorized(id: String, requesterId: String) {
val status = authorize(id, requesterId)
if (status is Failed) throw status.toException()
}
{
"success": false,
"name" : "INVALID_VALUE",
"group" : "Invalid",
"origin" : "kiit.dev",
"message": "The request had an invalid value."
}
fun validatePhone(phone: String): Checked {
return if (phone.isNotEmpty()) Checked.success()
else Checked.failure(
Invalid.INVALID_VALUE,
listOf(Err.on("phone", phone, "Too short")))
}
try {
throw StatusException.InvalidException(Invalid.INVALID_VALUE)
} catch (e: StatusException.InvalidException) {
// handle the invalid value
}
fun validatePhone(phone: String): Result<String, Err> =
when {
phone.isNotEmpty() -> Success(phone)
else -> Failure(
Err.on("phone", phone, "Too short"),
Invalid.INVALID_VALUE
)
}
Protocols
Working code for the types introduced in Explanation: mapping statuses to HTTP, gRPC, and a custom protocol of your own.
// HTTP, via CodesToHttp
val http = CodesToHttp()
http.toCode(Succeeded.CREATED) // 201
http.toCode(Invalid.INVALID_VALUE) // 400
http.toCode(Rejected.CONFLICT) // 409
// gRPC, via CodesToGrpc
val grpc = CodesToGrpc()
grpc.toCode(Restricted.DENIED) // 7, PERMISSION_DENIED
grpc.toCode(Rejected.CONFLICT) // 6, ALREADY_EXISTS
// Custom protocols, via CodeLookup and CompositeLookup
val lookup = CompositeLookup(
base = CodesToHttp(),
extensions = mapOf(PAYMENT_DECLINED to 402),
)
lookup.toCode(PAYMENT_DECLINED) // 402
Reference
Lookup tables. The ideas behind them are in Explanation.
Passed
Passed.success == true.
| Group | Code | Description |
|---|---|---|
| Succeeded | SUCCESS | The operation completed successfully. |
| CREATED | A new resource was created. | |
| UPDATED | The resource was fully updated. | |
| PATCHED | The resource was partially updated. | |
| FETCHED | The resource was retrieved. | |
| DELETED | The resource was deleted. | |
| HANDLED | The request was handled; nothing to return. | |
| REFERRED | The result is at another location. | |
| EXITED | The application exited cleanly. | |
| Pending | ACCEPTED | The request was accepted. |
| QUEUED | The request is waiting to be processed. | |
| PROCESSING | The request is being processed. | |
| CONFIRM | The request is awaiting confirmation. | |
| REDIRECTED | This request is being handled elsewhere. | |
| SCHEDULED | The operation is scheduled for later. | |
| Excluded | OMITTED | The item was excluded from the result. |
| SKIPPED | The item was not processed. | |
| DISCARDED | The item was processed, then excluded for unrelated reasons. | |
| CANCELLED | The operation was cancelled by the caller before completion. | |
| DEDUPLICATED | The duplicate item was not processed. | |
| DISQUALIFIED | The item was disqualified. | |
| Information | NOTICE | An informational notice. |
| ADVISORY | A notice that may need attention. | |
| METADATA | Information about the application itself was returned. | |
| HEALTH | The service is healthy and operational. | |
| DIAGNOSTICS | Diagnostic or operational information was returned. | |
| MOVED | The resource has permanently moved to a new location. |
Failed
Failed.success == false.
| Group | Code | Description |
|---|---|---|
| Restricted | DENIED | The request was denied. |
| UNAUTHENTICATED | Authentication is required. | |
| UNAUTHORIZED | The caller lacks permission. | |
| FORBIDDEN | Access to this resource is forbidden. | |
| LOCKED | Access is locked; resolve the condition to restore access. | |
| SUSPENDED | Access has been administratively suspended. | |
| Invalid | INVALID_VALUE | The request had an invalid value. |
| BAD_REQUEST | The request was malformed. | |
| NOT_FOUND | The requested route or endpoint does not exist. | |
| OUT_OF_RANGE | A value was outside the acceptable range. | |
| PAYLOAD_TOO_LARGE | The payload is too large. | |
| MISSING_FIELD | A required field was not provided. | |
| Rejected | RULE_VIOLATION | A business rule rejected the request. |
| CONFLICT | The request conflicts with the current state. | |
| NOT_EXISTS | The referenced item does not exist. | |
| PRECONDITION_FAILED | A required precondition was not met. | |
| EXPIRED | The item has expired. | |
| GONE | The resource was removed and is no longer available. | |
| Unserved | UNEXPECTED | An unexpected, unclassified error occurred. |
| UNSUPPORTED | This capability is not currently available. | |
| TIMEOUT | The operation timed out. | |
| RATE_LIMITED | Too many requests; try again later. | |
| RESOURCE_LIMITED | A resource limit has been reached. | |
| UNREACHABLE | A required dependency could not be reached. | |
| UNDER_MAINTENANCE | The service is temporarily under maintenance. | |
| INTERNAL | An internal invariant was violated. | |
| DATA_LOSS | Unrecoverable data loss or corruption occurred. | |
| DEGRADED | This dependency is degraded; some calls may be refused. | |
| LEGAL_BLOCK | Access is blocked for legal reasons. | |
| ABORTED | The operation was aborted; retrying may help. |
Defaults
Each group has one default code, for when nothing more specific applies. DEFAULT on a group is an alias for that
code, so Succeeded.DEFAULT is Succeeded.SUCCESS: the same instance, not a separate code.
| Group | Alias | Code | Description |
|---|---|---|---|
| Succeeded | Succeeded.DEFAULT | SUCCESS | The operation completed successfully. |
| Pending | Pending.DEFAULT | ACCEPTED | The request was accepted. |
| Excluded | Excluded.DEFAULT | OMITTED | The item was excluded from the result. |
| Information | Information.DEFAULT | NOTICE | An informational notice. |
| Restricted | Restricted.DEFAULT | DENIED | The request was denied. |
| Invalid | Invalid.DEFAULT | INVALID_VALUE | The request had an invalid value. |
| Rejected | Rejected.DEFAULT | RULE_VIOLATION | A business rule rejected the request. |
| Unserved | Unserved.DEFAULT | UNEXPECTED | An unexpected, unclassified error occurred. |
- Only the default:
isDefaultis true for a group's default code and for no other. - Every field counts: A copy with any field changed, such as the message, is not the default.
Err types
The kinds of Err and the builders that create them.
| Variant | Fields | Use |
|---|---|---|
Err.ErrorInfo | message, cause?, ref? | Default implementation: a message with an optional cause. |
Err.ErrorField | field, value, message, cause?, ref? | An error on a specific field. |
Err.ErrorList | errors, message, cause?, ref? | Wraps a list of other errors. |
| Builder | Use |
|---|---|
Err.of(message) | Plain message, no field or cause. |
Err.of(status) | Build directly from a Status. |
Err.on(field, value, message) | Error on a specific field, including its value. |
Err.on(field, message) | Same, but omits the value — for sensitive fields. |
Err.ex(throwable) | Wrap a caught exception or throwable. |
Err.obj(any) | Wrap an arbitrary object as the cause. |
Err.list(strings, message) | Build an Err.ErrorList from a list of plain strings. |
Err.build(any?) | Generic builder that dispatches based on the input's type. |
Protocol mappings
Every built-in code mapped to HTTP and gRPC. A code with no mapping of its own takes its group's default, so most of a group shares one value. There is no reverse conversion, since many codes share one protocol code.
| Group | Code | HTTP | gRPC |
|---|---|---|---|
| Succeeded | SUCCESS | 200 OK | 0 OK |
| CREATED | 201 Created | 0 OK | |
| UPDATED | 200 OK | 0 OK | |
| PATCHED | 200 OK | 0 OK | |
| FETCHED | 200 OK | 0 OK | |
| DELETED | 200 OK | 0 OK | |
| HANDLED | 204 No Content | 0 OK | |
| REFERRED | 200 OK | 0 OK | |
| EXITED | 200 OK | 0 OK | |
| Pending | ACCEPTED | 202 Accepted | 0 OK |
| QUEUED | 202 Accepted | 0 OK | |
| PROCESSING | 202 Accepted | 0 OK | |
| CONFIRM | 200 OK | 0 OK | |
| REDIRECTED | 307 Temporary Redirect | 0 OK | |
| SCHEDULED | 202 Accepted | 0 OK | |
| Excluded | OMITTED | 200 OK | 0 OK |
| SKIPPED | 200 OK | 0 OK | |
| DISCARDED | 200 OK | 0 OK | |
| CANCELLED | 499 Client Closed Request | 1 CANCELLED | |
| DEDUPLICATED | 200 OK | 0 OK | |
| DISQUALIFIED | 200 OK | 0 OK | |
| Information | NOTICE | 200 OK | 0 OK |
| ADVISORY | 200 OK | 0 OK | |
| METADATA | 200 OK | 0 OK | |
| HEALTH | 200 OK | 0 OK | |
| DIAGNOSTICS | 200 OK | 0 OK | |
| MOVED | 200 OK | 0 OK | |
| Restricted | DENIED | 401 Unauthorized | 7 PERMISSION_DENIED |
| UNAUTHENTICATED | 401 Unauthorized | 16 UNAUTHENTICATED | |
| UNAUTHORIZED | 401 Unauthorized | 7 PERMISSION_DENIED | |
| FORBIDDEN | 403 Forbidden | 7 PERMISSION_DENIED | |
| LOCKED | 423 Locked | 7 PERMISSION_DENIED | |
| SUSPENDED | 403 Forbidden | 7 PERMISSION_DENIED | |
| Invalid | INVALID_VALUE | 400 Bad Request | 3 INVALID_ARGUMENT |
| BAD_REQUEST | 400 Bad Request | 3 INVALID_ARGUMENT | |
| NOT_FOUND | 404 Not Found | 5 NOT_FOUND | |
| OUT_OF_RANGE | 400 Bad Request | 11 OUT_OF_RANGE | |
| PAYLOAD_TOO_LARGE | 413 Payload Too Large | 8 RESOURCE_EXHAUSTED | |
| MISSING_FIELD | 400 Bad Request | 3 INVALID_ARGUMENT | |
| Rejected | RULE_VIOLATION | 409 Conflict | 9 FAILED_PRECONDITION |
| CONFLICT | 409 Conflict | 6 ALREADY_EXISTS | |
| NOT_EXISTS | 404 Not Found | 9 FAILED_PRECONDITION | |
| PRECONDITION_FAILED | 409 Conflict | 9 FAILED_PRECONDITION | |
| EXPIRED | 410 Gone | 9 FAILED_PRECONDITION | |
| GONE | 410 Gone | 9 FAILED_PRECONDITION | |
| Unserved | UNEXPECTED | 500 Internal Server Error | 2 UNKNOWN |
| UNSUPPORTED | 501 Not Implemented | 12 UNIMPLEMENTED | |
| TIMEOUT | 504 Gateway Timeout | 4 DEADLINE_EXCEEDED | |
| RATE_LIMITED | 429 Too Many Requests | 8 RESOURCE_EXHAUSTED | |
| RESOURCE_LIMITED | 429 Too Many Requests | 8 RESOURCE_EXHAUSTED | |
| UNREACHABLE | 503 Service Unavailable | 14 UNAVAILABLE | |
| UNDER_MAINTENANCE | 503 Service Unavailable | 13 INTERNAL | |
| INTERNAL | 503 Service Unavailable | 13 INTERNAL | |
| DATA_LOSS | 503 Service Unavailable | 15 DATA_LOSS | |
| DEGRADED | 503 Service Unavailable | 13 INTERNAL | |
| LEGAL_BLOCK | 451 Unavailable For Legal Reasons | 13 INTERNAL | |
| ABORTED | 503 Service Unavailable | 10 ABORTED |
| Mapping | Source |
|---|---|
| HTTP | CodesToHttp, its overrides |
| gRPC | CodesToGrpc, its overrides |
FAQ
Common questions about the taxonomy, design choices, alternatives, adoption, and project maturity.
Why
| Question | Answer |
|---|---|
| Why not just use exceptions or booleans? | Exceptions are thrown inconsistently across a codebase, and a boolean can't say why. This gives every outcome a shared shape, closed categories, open codes underneath. |
| Why a closed taxonomy but open codes? | Closed categories keep generic handling, exhaustive matching, logging, and protocol mappings consistent everywhere. Codes stay open so each domain can extend it freely. |
| Why classify outcomes if my domain errors already explain what happened? | Domain errors explain what happened in one domain. The taxonomy explains what kind of outcome it was, consistently, across every domain in the app. |
| Does this replace domain modeling? | No. It classifies outcomes; it doesn't replace aggregates, value objects, or domain events. An infrastructure-level vocabulary, not a competing one. |
Alternatives
| Question | Answer |
|---|---|
How is this different from Arrow's Either/Validated or kotlin-result? | Those give you a Result type with no taxonomy underneath, you supply the meaning yourself. This provides the taxonomy those types can build on, plus a working exception path. |
| Why not just use raw HTTP status codes everywhere? | A background job or CLI command doesn't have an HTTP status. HTTP was the closest precedent, and the taxonomy is validated against it, but it isn't scoped to HTTP. |
| Doesn't this lock me into Kiit's taxonomy? | The eight categories are closed and cross-validated against HTTP and gRPC. Every code inside them is yours to extend, and you're free to ignore the built-in ones entirely. |
API
| Question | Answer |
|---|---|
| Why not just use strings for status names? | Strings don't give you compiler-checked exhaustiveness, discoverability, or protocol mappings. The goal is consistent classification, not just naming. |
Why isn't Status just an enum? | Enums can't be extended by consumers. This lets every application define its own statuses while still participating in the same taxonomy. |
| Why exactly eight categories? | Every gRPC code and the most common HTTP codes map onto these eight without needing a ninth, tested directly against both. |
| Why was the numeric status code field removed? | An earlier version had one, and it invited the wrong inference, a number resembling an HTTP code but meaning something else. Real protocol numbers are available on demand, never implied. |
| Isn't 50+ codes a steep learning curve? | Most of the real cost is the eight categories, not the codes. Each category's default is a safe fallback; the rest is opt-in precision you reach for as needed. |
Why is Unserved so much bigger than the others? | Independent evidence, not an oversight, both HTTP and gRPC show the same clustering on their own for capacity and infrastructure failures. |
| Why isn't retry logic or severity built in? | Retryability cuts across categories rather than aligning with them; Unserved alone has both retryable and non-retryable codes. A dedicated Retry category was considered and rejected. |
| Doesn't a generic category lose domain-specific detail? | No, the category is deliberately coarse while the code stays domain-specific. PAYMENT_DECLINED and ORDER_CONFLICT can both be Rejected and still keep distinct identities. |
Adoption
| Question | Answer |
|---|---|
| What if I classify something incorrectly? | Nothing catastrophic, a status can be moved to a more appropriate category later. The taxonomy improves consistency, it doesn't enforce absolute correctness upfront. |
| What if my company already has its own status system? | You don't have to replace it overnight. Existing statuses can map into the taxonomy incrementally while keeping their original names and meanings. |
| How does this work across microservices? | Services don't need identical codes, only the shared categories. Each service keeps its own domain-specific statuses while exposing consistent high-level semantics. |
AI
| Question | Answer |
|---|---|
| Is the "built for AI" angle just marketing? | The design decisions are justified on ordinary engineering grounds first, consistency, exhaustive matching, explicit semantics. AI benefits from the same properties, but the library stands on its own without them. |
| What evidence supports the AI-related claims? | Intentionally modest. Stable names and explicit classification are expected to reduce ambiguity for AI tooling, but that's a hypothesis to validate with real benchmarks, not an assumed result. |
Maturity
| Question | Answer |
|---|---|
| Is this production-ready at 1.0.1? | The version reflects the public package's youth, not the underlying design's. The core classification has years of internal production use prior to extraction; newer pieces (JS/TS, iOS) have less track record. |
| What about single-maintainer risk? | Real risk, worth being upfront about. Apache 2.0 licensed and source available, but there's currently no second maintainer or organizational backing. |