Skip to main content

kiit-codes logokiit-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 RFC 9457 problem details.

Kiit Codes overview

Overview​

Goals​

General-purpose status codes. Like HTTP status codes, but not tied to HTTP or a specific protocol. A three-tier status taxonomy (Status → Group → Code) for any layer of your app, with built-in RFC 9457 Problem Details output.

This is how a code is constructed: a name, a title, an origin that says who owns it, and an optional scope inside that origin.

val MISSING_DATE = Invalid(
name = "MISSING_DATE",
title = "Date not supplied",
origin = "samples.kiit.dev",
scope = "tasks",
)
Inspiration
  1. HTTP and gRPC: The eight groups were validated against their status codes, not derived from them.
  2. RFC 9457: The problem details format shaped how a status becomes a response body.

Languages​

#TargetRoleStatusNotes
1KotlinOriginalLiveCanonical model, in production use for years.
2TypeScriptNative portBetaSame model, written in TypeScript.
3SwiftMultiplatformPOCXCFramework through SKIE.

License​

Refer to the LICENSE file in the repository.

Setup​

Install​

dependencies {
implementation("dev.kiit:kiit-codes:1.2.0")
}
#LanguageArtifactRegistry
1Kotlin, Javadev.kiit:kiit-codesMaven Central
2TypeScript@kiitdev/codesnpm
3SwiftKiitCodesSwift Package Manager, link to come

Imports​

import kiit.codes.*
import kiit.codes.formats.*

Sources​

#LinkDetails
1Repositorygithub.com/kiitdev/kiit-codes
2Kotlin sourcekiit-codes/src/commonMain/kotlin
3TypeScript portports/kiit-codes-ts
4Sample appssamples

Tutorial​

A to-do list, one quick win at a time. Each step builds on the last.

Return a status​

Return a Status instead of throwing for a failure you expect:

// A failure you expect is a value, not an exception
fun validate(title: String): Status {
return if (title.isBlank()) {
Invalid.INVALID_VALUE
} else {
Succeeded.SUCCESS
}
}

val good = validate("buy milk")
// success = true, group = Succeeded, name = SUCCESS
println("success = ${good.success}, group = ${good.group}, name = ${good.name}")

val bad = validate("")
// success = false, group = Invalid, name = INVALID_VALUE
println("success = ${bad.success}, group = ${bad.group}, name = ${bad.name}")

Validate and Match​

Return a Checked to carry the status and the errors together, then match on whether it passed or failed:

// Checked carries the status and the errors together
fun validate(title: String): Checked =
if (title.isNotBlank()) {
Checked.success()
} else {
Checked.failure(
Invalid.INVALID_VALUE,
listOf(
Err.on("title", title, "must not be blank"),
),
)
}

val checked = validate("")
// failed: INVALID_VALUE
when (val status = checked.status) {
is Passed -> println("ok: ${status.name}")
is Failed -> println("failed: ${status.name}")
}
Match in more depth
  1. By group: Branch on Restricted, Invalid, Rejected or Unserved inside Failed.
  2. By code: Branch on one specific code, such as Rejected.CONFLICT, before the broader branches.
  3. More: See Status: Pattern matching.

See Errors for the types.

Build an RFC 9457 problem​

Send the failure to an HTTP client as an RFC 9457 problem, the IETF standard for API errors that clients and tools already understand. The errors fill detail and errors, and the problem also carries code, the exact status. For calls between your own services, the same failure as kiit's self-contained CodeDetail takes one more line:

val errors =
Err.ErrorList(
errors = checked.errors,
message = "Validation failed",
)

// The failure as an RFC 9457 problem, for an HTTP API
val problem = ProblemConverter().convert(checked.status, errors)
// 400
println(problem.status)
// 1
println(problem.errors?.size)

// The same failure as kiit's CodeDetail, self-contained, for your own services
val detail = toCodeDetail(checked.status, errors)
// kiit.dev:codes:Failed:Invalid:INVALID_VALUE
println(detail.code)
// false
println(detail.success)

The problem as JSON:

RFC 9457 problem
{
"type": "https://www.kiit.dev/docs/kiit-codes?code=Failed:Invalid:INVALID_VALUE#codes",
"title": "The request had an invalid value.",
"detail": "Validation failed",
"code": "kiit.dev:codes:Failed:Invalid:INVALID_VALUE",
"status": 400,
"errors": [
{
"field": "title",
"message": "must not be blank"
}
]
}

The CodeDetail as JSON:

CodeDetail (self-contained)
{
"code": "kiit.dev:codes:Failed:Invalid:INVALID_VALUE",
"title": "The request had an invalid value.",
"detail": "Validation failed",
"success": false,
"errors": [
{
"field": "title",
"message": "must not be blank"
}
]
}

See the Guide to write both as JSON.

Explanation​

Taxonomy​

A Status is the outcome of any operation, at any layer: a service call, a job step, an API request, a CLI command. It says what kind of success or failure happened, in one shape everywhere, and nothing about this one occurrence. Those details belong to an Err. The tiers are Status, then Group, then Code: the two Status branches, the eight groups, and the codes within them.

Kiit Codes taxonomy
TierParentFixed/OpenChildrenDescription
1Status FixedPassed
Failed
2Group FixedSucceededThe operation completed successfully.
PendingThe operation was accepted but has not yet fully resolved.
ExcludedThe item was intentionally excluded from the operation.
InformationThe response provides information; no operation was performed.
RestrictedThe caller is not allowed.
InvalidThe request itself is wrong.
RejectedThe caller was allowed, but the business refuses it.
UnservedThe system can't serve it right now, though nothing was wrong with the request.
3Code Open + DefaultsShips with common built-in codes (e.g. SUCCESS, DENIED); extensible with custom, domain-specific codes within the same group.

Each group has a default code for when nothing more specific applies. Invalid.DEFAULT is INVALID_VALUE, and isDefault is true for a group's default and for no other code. The full list is in Codes.

Kiit Codes custom codes

Every Status carries the same six fields, built-in or custom:

FieldWhy it exists
successtrue for Passed, false for Failed. A quick check that doesn't need the group.
groupThe kind of outcome (Succeeded, Invalid, ...). Lets generic code handle any Status, including custom ones, without knowing its name.
nameA stable, CAPITAL_SNAKE_CASE key that logs, metrics and clients can match on. Never built from runtime data.
titleA constant, human-readable title for the code, not a description of one occurrence. It fills title in a Problem and a CodeDetail. Per-occurrence detail lives in an Err.
originWho 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.
scopeAn optional label inside an origin, such as a department or product area (payments.cards). Empty when unset. Never parsed.
Field rules
  1. Name: CAPITAL_SNAKE_CASE by convention. Letters, digits, ., - and _, starting with a letter or digit.
  2. Scope: Levels split by ., such as payments.cards. Same characters as the name, and it may be empty.
  3. Origin: A domain you own or a specific name. Same characters as the name. kiit.dev is reserved for kiit-authored codes.
  4. As written: code and type use these values exactly as you write them. Case is not changed.
  5. Checked: Creating a status with any other character, or an empty name or origin, throws. StatusRules.check tests values without throwing.
Choose a specific origin
  1. Domain: A domain you own is unique through DNS, so your codes can't collide with anyone else's.
  2. Plain id: A plain id such as myapp1 can collide with another team's, and kiit-codes can't detect it. Pick a specific name.
  3. Also a host: A domain origin becomes the host of the RFC 9457 type, for example https://samples.kiit.dev/docs/codes/.... A plain id is not a host, so its type is the relative /docs/codes/.... Register a base URL to get an absolute one.

Errors​

A Status is the kind of outcome. The details of one occurrence travel separately, in three types:

TypeWhat it is
ErrThe details of one occurrence: which field, what value, what message. The variants and builders are in Err types.
CheckedA status and its errors together, so they never disagree: a passing Checked has no errors, a failing one has at least one. collect(...) combines several into one.
StatusExceptionFor boundaries that only understand exceptions, with one subclass per failed group: RestrictedException, InvalidException, RejectedException and UnservedException. Failed.toException() picks the right one. Each subclass is open, so your own exception can extend one and carry its own data.
Status or Err?
  1. Status: The kind of outcome, constant: Invalid.INVALID_VALUE.
  2. Err: The details of this occurrence: which field, what value, what message.
  3. Together: Checked carries both, so a status and its errors travel together.

See Validate and Match and Exceptions.

Responses​

How a status becomes a protocol code. For a response body, see RFC 9457.

Kiit Codes protocol mappings
TypePurpose
CodesToHttpMaps Status to HTTP status codes.
CodesToGrpcMaps Status to gRPC status codes.
CodeLookupInterface for a mapping to any other protocol.
CompositeLookupCombines a base CodeLookup with per-code overrides.
One way only
  1. No reverse conversion: An HTTP or gRPC code can't give back a Status, since many share one code.
  2. Carry the status instead: Send the code of a CodeDetail or Problem, the exact status.

RFC 9457​

RFC 9457 is the IETF standard for error responses from HTTP APIs, and it replaces RFC 7807. kiit-codes returns it as a Problem, so clients and tools that already understand problem details work with it as they are. It adds errors and code as extension members. For calls between your own services, where HTTP and a public URL don't apply, CodeDetail is a lighter, self-contained shape. The same validation failure in both:

Problem (RFC 9457)
{
"type": "https://www.kiit.dev/docs/kiit-codes?code=Failed:Invalid:INVALID_VALUE#codes",
"title": "The request had an invalid value.",
"detail": "Validation failed",
"code": "kiit.dev:codes:Failed:Invalid:INVALID_VALUE",
"status": 400,
"errors": [
{
"field": "title",
"message": "must be 1-100 characters"
},
{
"field": "listId",
"message": "unknown list"
}
]
}
CodeDetail (self-contained)
{
"code": "kiit.dev:codes:Failed:Invalid:INVALID_VALUE",
"title": "The request had an invalid value.",
"detail": "Validation failed",
"success": false,
"errors": [
{
"field": "title",
"message": "must be 1-100 characters"
},
{
"field": "listId",
"message": "unknown list"
}
]
}

They use the same field names:

FieldProblemCodeDetail
typethe problem's identity and its docs URLnone
codethe exact status, an extension memberthe exact status
titlethe fixed text of the statusthe fixed text of the status
detailthis occurrencethis occurrence
instancethis occurrencethis occurrence
errorsevery error, an extension memberevery error
statusthe HTTP status, always setthe HTTP status, optional
successnonetrue or false
Three differences on purpose
  1. type: Only Problem has it. RFC 9457 makes it the problem's identifier and docs link.
  2. status: Optional on CodeDetail, since an HTTP status only makes sense over HTTP.
  3. success: Only on CodeDetail, so a reader sees pass or fail without parsing code.

code is the origin, the scope and then the status code, so one field says which status it is. Built-in statuses use the scope codes. type names the same status as a URL, and the two differ:

Problem.typecode
Identityyes, RFC 9457's primary identifieryes, exact
Docsyes, when it resolvesno
Self-containedno, needs a host or a baseyes
Caseas writtenas written

Every error goes into errors. Each entry has only a field and a message. The cause and the value that failed validation are left out on purpose, since echoing them back is a disclosure risk.

Which one?
  1. Problem: An HTTP API that other parties call.
  2. CodeDetail: Calls between your own services, and jobs.
  3. Neither: When the status alone is enough.
  4. Passed statuses: RFC 9457 describes problems, so use a Problem for Failed and a CodeDetail for Passed.

Security​

Every status carries an origin, a scope and a name, and all three end up in what a client sees. Treat them as public, like a route in your API. RFC 9457 asks that the information in a problem type is carefully vetted, and that the details of a generated problem are scrutinized. See section 5 of the RFC for more.

Where the values show
{
"type": "https://stripe.com/docs/codes/payments/cards/Failed/Rejected/DUPLICATE_CHARGE",
"code": "stripe.com:payments.cards:Failed:Rejected:DUPLICATE_CHARGE"
}
ValueFine to showAvoid
originstripe.combilling-internal.corp.example.com, an internal host
scopepayments.cardspayments.fraud-review, an internal step
nameDUPLICATE_CHARGEBETA_WALLET_LIMIT, a feature that has not launched

kiit-codes doesn't inspect these values. The baseUrls of ProblemConverter, or a type builder, change type, but code always carries the origin and the scope. See Response: Keep internals out. detail, instance and errors describe one occurrence, so keep stack traces and internal identifiers out of them.

Guide​

Recipes for common tasks, grouped like the topics in Explanation.

#RecipeUse it to
1Status: Built-insUse a standard outcome, or a group's default
2Status: Custom codeName an outcome that is specific to your domain
3Status: Pattern matchingTurn a status into a response or a decision
4Error Handling: Collect errorsReport all the problems at once
5Error Handling: Error detailsSay which field failed and why
6Error Handling: ExceptionsCross a boundary that only understands exceptions, or add your own data to one
7Error Handling: ResultPair a value with a status, with kiit-result
8Response: JSONSend an error body to a client
9Response: Type URLKnow which type a status produces
10Response: Custom type URLChange the suffix, the base, or the whole URL
11Response: Keep internals outKeep an internal host or scope out of a response
12Response: HTTP and gRPCPick the response code at the edge of your service
13Response: Custom protocolGive a custom code its own value, such as 402

Status: Built-ins​

Use a built-in code for a standard outcome. When you only know the kind of outcome and not the exact code, use the group's default. Statuses are values, so a test can compare them directly with assertEquals(Rejected.CONFLICT, tasks.create("groceries")).

// A specific built-in code
val created = Succeeded.CREATED
// The group's default, when you only know the kind of outcome
val failed = Invalid.DEFAULT
// INVALID_VALUE
println(failed.name)
// true
println(failed.isDefault)
// false
println(Invalid.BAD_REQUEST.isDefault)

Status: Custom code​

Use it when a built-in code is too generic for your domain, such as a payment that was declined. A custom code stays in one of the eight groups, so generic handling still works on it. Pick an origin you own, such as a domain.

val PAYMENT_DECLINED =
Rejected(
name = "PAYMENT_DECLINED",
title = "Payment declined",
origin = "payments.example.com",
scope = "payments.cards",
)

// A bad origin, scope or name throws when the status is created. For values from config, check them first
// null, the values are valid
println(StatusRules.check(origin = "payments.example.com", scope = "payments.cards", name = "PAYMENT_DECLINED"))
// origin "host:8080" is invalid: allowed characters are a-z, A-Z, 0-9, '.', '-', '_'
println(StatusRules.check(origin = "host:8080", scope = "", name = "PAYMENT_DECLINED"))

Status: Pattern matching​

Use it to turn a status into a response, a log level or a retry decision. Match a code first, then a group, then the broad branches, because the first branch that fits wins and a broader branch above a specific one means the specific one never runs.

// 1. Passed or Failed
fun binary(status: Status): String =
when (status) {
is Passed -> "ok: ${status.name}"
is Failed -> "failed: ${status.name}"
}

// 2. Passed or Failed, each by its four groups. The compiler flags a missing group.
fun nested(status: Status): String =
when (status) {
is Passed ->
when (status) {
is Succeeded -> "done"
is Pending -> "in progress"
is Excluded -> "skipped"
is Information -> "for your information"
}
is Failed ->
when (status) {
is Restricted -> "not allowed"
is Invalid -> "fix the input"
is Rejected -> "refused by a rule"
is Unserved -> "try again later"
}
}

// 3. Specific to broad: a code, then a group, then Failed or Passed
fun hybrid(status: Status): String =
when (status) {
Rejected.CONFLICT -> "already exists"
is Invalid -> "fix the input"
is Failed -> "failed: ${status.name}"
is Passed -> "ok: ${status.name}"
}

// failed: INVALID_VALUE
println(binary(Invalid.INVALID_VALUE))
// in progress
println(nested(Pending.QUEUED))
// already exists
println(hybrid(Rejected.CONFLICT))

Error Handling: Collect errors​

Use it for a form or a request with several fields, so the caller sees every problem in one response and not one at a time.

fun validateTitle(title: String): Checked =
if (title.isNotBlank()) {
Checked.success()
} else {
Checked.failure(Invalid.INVALID_VALUE, listOf(Err.on("title", title, "must not be blank")))
}

fun validateListId(listId: String): Checked =
if (listId in setOf("personal", "team")) {
Checked.success()
} else {
Checked.failure(Invalid.NOT_FOUND, listOf(Err.on("listId", listId, "unknown list")))
}

val checked =
collect(
validateTitle(""),
validateListId("unknown-list"),
)
// valid = false, errors = 2
println("valid = ${checked.isValid}, errors = ${checked.errors.size}")

Error Handling: Error details​

Use it to say which field failed and why. Leave the value out for a sensitive field, so it is never echoed back.

// A plain message
val plain = Err.of("title is required")
// An error on one field, with its value
val title = Err.on("title", "", "must be 1-100 characters")
// The same, without the value
val password = Err.on("password", "must be at least 12 characters")
// Several plain messages under one message
val many = Err.list(listOf("title is required", "list is unknown"), "Validation failed")

Error Handling: Exceptions​

Use it when a framework or a callback only understands exceptions. toException() picks the subclass for the status group, so the caller can catch the kind of failure it cares about. In Java it is a checked exception. Kotlin exceptions do not bridge to Swift's Error, so there is no Swift tab.

fun create(title: String): Status =
if (title == "groceries") Rejected.CONFLICT else Succeeded.CREATED

fun createOrThrow(title: String) {
val status = create(title)
if (status is Failed) throw status.toException()
}

try {
createOrThrow("groceries")
} catch (e: StatusException.RejectedException) {
// CONFLICT
println(e.status.name)
}

To carry data of your own, extend the subclass for the group. The status is fixed by the group you extend, and status has that narrow type, so a RestrictedException gives you a Failed.Restricted with no cast. message is the one line summary and defaults to the status title. errors is the full list and is separate from message, so setting one does not change the other.

Match the narrow type before its group. The first match wins, so a when or a catch that lists the group first never reaches your subclass. Kotlin does not warn about that order, and Java rejects it at compile time. The iOS *Error classes are final, so they cannot be extended from Swift.

// A domain exception: the status is fixed, the data and the message are yours
class OrderShippedException(val orderId: String) :
StatusException.RejectedException(Rejected.CONFLICT, message = "Order $orderId already shipped")

fun describe(e: StatusException): String =
when (e) {
// The narrow type goes first, the first match wins
is OrderShippedException -> "order ${e.orderId} shipped"
is StatusException.RejectedException -> "rejected"
is StatusException.RestrictedException -> "restricted"
is StatusException.InvalidException -> "invalid"
is StatusException.UnservedException -> "unserved"
}

// order A-1 shipped
println(describe(OrderShippedException("A-1")))
// rejected
println(describe(Rejected.CONFLICT.toException()))

try {
throw OrderShippedException("A-1")
} catch (e: OrderShippedException) {
// Order A-1 already shipped
println(e.message)
} catch (e: StatusException.RejectedException) {
println(e.status.name)
}

Error Handling: Result​

Use kiit-result when a function should return a value on success and a failure otherwise. It builds a Result<T, E> on this same taxonomy.

Response: JSON​

Use a Problem for an HTTP API that other parties call, and a CodeDetail between your own services. kiit-codes has no JSON dependency. In Kotlin its classes are not @Serializable, so map each one to a small class of your own, and the order you declare the fields is the order in the JSON. In TypeScript they are plain objects, so JSON.stringify works on them and listing the fields sets the order. Java and Swift have no example here, so use the JSON library you already have.

@Serializable
data class ErrorJson(val field: String? = null, val message: String)

@Serializable
data class ProblemJson(
val type: String,
val title: String,
val detail: String? = null,
val code: String? = null,
val status: Int? = null,
val errors: List<ErrorJson>? = null,
)

@Serializable
data class CodeDetailJson(
val code: String,
val title: String,
val detail: String? = null,
val success: Boolean,
val errors: List<ErrorJson>? = null,
)

fun Problem<ErrorDetail>.toJson() =
ProblemJson(type, title, detail, code, status, errors?.map { ErrorJson(it.field, it.message) })

fun CodeDetail<ErrorDetail>.toJson() =
CodeDetailJson(code, title, detail, success, errors?.map { ErrorJson(it.field, it.message) })

val json = Json { prettyPrint = true }
val errors = Err.ErrorList(listOf(Err.on("title", "", "must be 1-100 characters")), "Validation failed")

// An RFC 9457 problem, for an HTTP API
println(json.encodeToString(ProblemConverter().convert(Invalid.INVALID_VALUE, errors).toJson()))
// A CodeDetail, for your own services
println(json.encodeToString(toCodeDetail(Invalid.INVALID_VALUE, errors).toJson()))

Response: Type URL​

Use it to know which type your statuses produce, and to make it point at your own docs. A custom code with the origin stripe.com gives a type like this:

https://stripe.com/docs/codes/payments/cards/Failed/Rejected/DUPLICATE_CHARGE
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ ^^^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^^
base scope status group name

type is the base URL plus a suffix. The suffix is the scope, then the status, group and name. The base is chosen like this:

You provideOriginBase URL
Nothingkiit.dev, a built-inhttps://www.kiit.dev/docs/kiit-codes, fixed
NothingA domain, such as stripe.comhttps://stripe.com/docs/codes
NothingA plain id, such as myapp1/docs/codes, relative
A baseUrls entry for the originAny except kiit.devYour URL, always absolute
convertWithUrl(baseUrl = …)AnyThe URL you pass for that call
Rules
  1. Tail: {scope}/{status}/{group}/{name}, the values of Status.code with / in place of :.
    • Status: Passed or Failed.
    • Group: the group of the code, such as Rejected.
    • Name: the name of the code, such as DUPLICATE_CHARGE.
  2. Scope: Each . becomes /, so payments.cards is payments/cards. An empty scope is skipped.
  3. Origin: It picks the base URL and is not in the tail. A domain is recognized by its form alone, with no DNS lookup.
  4. Names: A name and a scope follow the field rules.
  5. Stable: type is the problem's identity, so pick the base once and keep it.
  6. Built-ins: A built-in links to the Codes section of this page, as ?code=Failed:Invalid:INVALID_VALUE#codes, and a baseUrls entry for kiit.dev is ignored. The origin is reserved for kiit-authored codes, so a custom status must not use it.
  7. Read code: The tail is a readable form of the same values as code. Read code for the origin, the scope and the status.
val stripe = Rejected("DUPLICATE_CHARGE", "Duplicate charge", origin = "stripe.com", scope = "payments.cards")
val plain = Rejected("OUT_OF_STOCK", "Out of stock", origin = "myapp1")

// 1. A domain origin and nothing registered
// https://stripe.com/docs/codes/payments/cards/Failed/Rejected/DUPLICATE_CHARGE
println(ProblemConverter().convert(stripe).type)

// 2. A plain id is not a domain, so the type is relative
// /docs/codes/Failed/Rejected/OUT_OF_STOCK
println(ProblemConverter().convert(plain).type)

// 3. A base URL registered for the origin
// https://stripe.com/errors/payments/cards/Failed/Rejected/DUPLICATE_CHARGE
val registered = ProblemConverter(baseUrls = mapOf("stripe.com" to "https://stripe.com/errors"))
println(registered.convert(stripe).type)

Response: Custom type URL​

Use it when the default suffix or base does not fit, such as docs that live under your own path. There is no new API, so pick the way that fits how much of the URL you control. Kotlin and TypeScript can also copy the Problem with any type, the Java and Swift examples leave that out.

val stripe = Rejected("DUPLICATE_CHARGE", "Duplicate charge", origin = "stripe.com", scope = "payments.cards")
val problems = ProblemConverter()

// 1. Your own suffix, with a type builder
// https://stripe.com/docs/codes/charges/duplicate
println(problems.convert(stripe, typeBuilder = { "charges/duplicate" }).type)

// 2. Exactly one URL, a base with an empty suffix
// https://example.com/probs/duplicate-charge
println(
problems.convertWithUrl(
stripe,
baseUrl = "https://example.com/probs/duplicate-charge",
typeBuilder = { "" },
).type,
)

// 3. Any URL at all, by copying the Problem
// https://other.example.org/probs/duplicate
println(problems.convert(stripe).copy(type = "https://other.example.org/probs/duplicate").type)
code does not change

code is built from the status, not from type, so it is the same however type was built. A client that reads code is not affected.

Response: Keep internals out​

Use it when an origin or a scope names something clients shouldn't see, such as an internal host or a team. ProblemConverter builds type, and it takes a baseUrls map of origin to base URL. There is no new API. Pick the option by what you want to hide:

HideHow
The host in typePass baseUrls to ProblemConverter, with the origin mapped to a public URL.
The scope in typePass a type builder to ProblemConverter.convert that leaves it out, such as { it.code.replace(':', '/') } in Kotlin.
The origin or scope in codeUse other values. code is always built from them.

The default output for an internal origin and scope:

Default
{
"type": "https://billing-internal.corp.example.com/docs/codes/payments/fraud-review/Failed/Rejected/DUPLICATE_CHARGE",
"code": "billing-internal.corp.example.com:payments.fraud-review:Failed:Rejected:DUPLICATE_CHARGE"
}

With ProblemConverter(baseUrls = mapOf("billing-internal.corp.example.com" to "https://api.example.com/errors")) and the type builder above, type no longer shows the host or the scope, and code is the same:

Public base, no scope in type
{
"type": "https://api.example.com/errors/Failed/Rejected/DUPLICATE_CHARGE",
"code": "billing-internal.corp.example.com:payments.fraud-review:Failed:Rejected:DUPLICATE_CHARGE"
}
code does not change

code shows the origin and the scope however type was built. If those values are internal, choose public ones for the status itself.

See Response: Custom type URL for each way to build type, in every language, and Security for why.

Response: HTTP and gRPC​

Use it at the edge of your service, where an outcome becomes a response code. There is no reverse conversion, because many statuses share one code. The TypeScript port has HTTP only for now, with no gRPC mapping.

val http = CodesToHttp()
val grpc = CodesToGrpc()

// 201
println(http.toCode(Succeeded.CREATED))
// 409
println(http.toCode(Rejected.CONFLICT))

// 0 (OK)
println(grpc.toCode(Succeeded.CREATED))
// 6 (ALREADY_EXISTS)
println(grpc.toCode(Rejected.CONFLICT))

Response: Custom protocol​

Use it when a custom code needs its own protocol value, such as HTTP 402 for a declined payment. Your values come first and the base mapping answers for everything else. Kotlin has CompositeLookup for this. The TypeScript port does not yet, so the lookup is written by hand.

val PAYMENT_DECLINED = Rejected("PAYMENT_DECLINED", "Payment declined", origin = "payments.example.com")

val http =
CompositeLookup(
base = CodesToHttp(),
extensions = mapOf(PAYMENT_DECLINED to 402),
)
// 402
println(http.toCode(PAYMENT_DECLINED))
// 409
println(http.toCode(Rejected.CONFLICT))

Reference​

Lookup tables. The ideas behind them are in Explanation.

Codes​

The first code of each group is its default. Group.DEFAULT is an alias for it, so Succeeded.DEFAULT is Succeeded.SUCCESS, the same instance. isDefault is true for a group's default code and for no other, and it compares by value, so a copy with any field changed is not the default.

Passed (success == true)

GroupCodeDescription
SucceededSUCCESSThe operation completed successfully.
CREATEDA new resource was created.
UPDATEDThe resource was fully updated.
PATCHEDThe resource was partially updated.
FETCHEDThe resource was retrieved.
DELETEDThe resource was deleted.
HANDLEDThe request was handled; nothing to return.
REFERREDThe result is at another location.
EXITEDThe application exited cleanly.
PendingACCEPTEDThe request was accepted.
QUEUEDThe request is waiting to be processed.
PROCESSINGThe request is being processed.
CONFIRMThe request is awaiting confirmation.
REDIRECTEDThis request is being handled elsewhere.
SCHEDULEDThe operation is scheduled for later.
ExcludedOMITTEDThe item was excluded from the result.
SKIPPEDThe item was not processed.
DISCARDEDThe item was processed, then excluded for unrelated reasons.
CANCELLEDThe operation was cancelled by the caller before completion.
DEDUPLICATEDThe duplicate item was not processed.
DISQUALIFIEDThe item was disqualified.
InformationNOTICEAn informational notice.
ADVISORYA notice that may need attention.
METADATAInformation about the application itself was returned.
HEALTHThe service is healthy and operational.
DIAGNOSTICSDiagnostic or operational information was returned.
MOVEDThe resource has permanently moved to a new location.

Failed (success == false)

GroupCodeDescription
RestrictedDENIEDThe request was denied.
UNAUTHENTICATEDAuthentication is required.
UNAUTHORIZEDThe caller lacks permission.
FORBIDDENAccess to this resource is forbidden.
LOCKEDAccess is locked; resolve the condition to restore access.
SUSPENDEDAccess has been administratively suspended.
InvalidINVALID_VALUEThe request had an invalid value.
BAD_REQUESTThe request was malformed.
NOT_FOUNDThe requested route or endpoint does not exist.
OUT_OF_RANGEA value was outside the acceptable range.
PAYLOAD_TOO_LARGEThe payload is too large.
MISSING_FIELDA required field was not provided.
RejectedRULE_VIOLATIONA business rule rejected the request.
CONFLICTThe request conflicts with the current state.
NOT_EXISTSThe referenced item does not exist.
PRECONDITION_FAILEDA required precondition was not met.
EXPIREDThe item has expired.
GONEThe resource was removed and is no longer available.
UnservedUNEXPECTEDAn unexpected, unclassified error occurred.
UNSUPPORTEDThis capability is not currently available.
TIMEOUTThe operation timed out.
RATE_LIMITEDToo many requests; try again later.
RESOURCE_LIMITEDA resource limit has been reached.
UNREACHABLEA required dependency could not be reached.
UNDER_MAINTENANCEThe service is temporarily under maintenance.
INTERNALAn internal invariant was violated.
DATA_LOSSUnrecoverable data loss or corruption occurred.
DEGRADEDThis dependency is degraded; some calls may be refused.
LEGAL_BLOCKAccess is blocked for legal reasons.
ABORTEDThe operation was aborted; retrying may help.

Err types​

The kinds of Err and the builders that create them.

VariantFieldsUse
Err.ErrorInfomessage, cause?, ref?Default implementation: a message with an optional cause.
Err.ErrorFieldfield, value, message, cause?, ref?An error on a specific field.
Err.ErrorListerrors, message, cause?, ref?Wraps a list of other errors.
BuilderUse
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.

GroupCodeHTTPgRPC
SucceededSUCCESS200 OK0 OK
CREATED201 Created0 OK
UPDATED200 OK0 OK
PATCHED200 OK0 OK
FETCHED200 OK0 OK
DELETED200 OK0 OK
HANDLED204 No Content0 OK
REFERRED200 OK0 OK
EXITED200 OK0 OK
PendingACCEPTED202 Accepted0 OK
QUEUED202 Accepted0 OK
PROCESSING202 Accepted0 OK
CONFIRM200 OK0 OK
REDIRECTED307 Temporary Redirect0 OK
SCHEDULED202 Accepted0 OK
ExcludedOMITTED200 OK0 OK
SKIPPED200 OK0 OK
DISCARDED200 OK0 OK
CANCELLED499 Client Closed Request1 CANCELLED
DEDUPLICATED200 OK0 OK
DISQUALIFIED200 OK0 OK
InformationNOTICE200 OK0 OK
ADVISORY200 OK0 OK
METADATA200 OK0 OK
HEALTH200 OK0 OK
DIAGNOSTICS200 OK0 OK
MOVED200 OK0 OK
RestrictedDENIED401 Unauthorized7 PERMISSION_DENIED
UNAUTHENTICATED401 Unauthorized16 UNAUTHENTICATED
UNAUTHORIZED401 Unauthorized7 PERMISSION_DENIED
FORBIDDEN403 Forbidden7 PERMISSION_DENIED
LOCKED423 Locked7 PERMISSION_DENIED
SUSPENDED403 Forbidden7 PERMISSION_DENIED
InvalidINVALID_VALUE400 Bad Request3 INVALID_ARGUMENT
BAD_REQUEST400 Bad Request3 INVALID_ARGUMENT
NOT_FOUND404 Not Found5 NOT_FOUND
OUT_OF_RANGE400 Bad Request11 OUT_OF_RANGE
PAYLOAD_TOO_LARGE413 Payload Too Large8 RESOURCE_EXHAUSTED
MISSING_FIELD400 Bad Request3 INVALID_ARGUMENT
RejectedRULE_VIOLATION409 Conflict9 FAILED_PRECONDITION
CONFLICT409 Conflict6 ALREADY_EXISTS
NOT_EXISTS404 Not Found9 FAILED_PRECONDITION
PRECONDITION_FAILED409 Conflict9 FAILED_PRECONDITION
EXPIRED410 Gone9 FAILED_PRECONDITION
GONE410 Gone9 FAILED_PRECONDITION
UnservedUNEXPECTED500 Internal Server Error2 UNKNOWN
UNSUPPORTED501 Not Implemented12 UNIMPLEMENTED
TIMEOUT504 Gateway Timeout4 DEADLINE_EXCEEDED
RATE_LIMITED429 Too Many Requests8 RESOURCE_EXHAUSTED
RESOURCE_LIMITED429 Too Many Requests8 RESOURCE_EXHAUSTED
UNREACHABLE503 Service Unavailable14 UNAVAILABLE
UNDER_MAINTENANCE503 Service Unavailable13 INTERNAL
INTERNAL503 Service Unavailable13 INTERNAL
DATA_LOSS503 Service Unavailable15 DATA_LOSS
DEGRADED503 Service Unavailable13 INTERNAL
LEGAL_BLOCK451 Unavailable For Legal Reasons13 INTERNAL
ABORTED503 Service Unavailable10 ABORTED
MappingSource
HTTPCodesToHttp, its overrides
gRPCCodesToGrpc, its overrides

FAQ​

Why​

QuestionAnswer
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 them.
Does this replace domain modeling?No. A domain error explains what happened in one domain, and the taxonomy explains what kind of outcome it was, across every domain in the app.
Why isn't Status just an enum?Enums can't be extended by consumers. This lets every application define its own statuses and still use the same taxonomy.
Why exactly eight groups?Every gRPC code and the most common HTTP codes map onto these eight without needing a ninth, tested directly against both.
Why isn't retry logic or severity built in?Retryability cuts across groups rather than aligning with them. Unserved alone has both retryable and non-retryable codes, so a dedicated Retry category was considered and rejected.
Why was the numeric status code field removed?An earlier version had one, and it invited the wrong inference: a number that looked like an HTTP code but meant something else. Real protocol numbers come from CodesToHttp and CodesToGrpc, never implied by the taxonomy.
What about single-maintainer risk?It is real. The project is Apache 2.0 licensed with the source available, but has no second maintainer or organizational backing yet.

Alternatives​

QuestionAnswer
How is this different from Arrow's Either/Validated or other Kotlin Result types?Those give you a Result type with no taxonomy underneath, so 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 the kiit-codes taxonomy?The eight groups are closed and cross-checked against HTTP and gRPC. Every code inside them is yours to extend, and you can ignore the built-in ones entirely.
What if my company already has its own status system?Map it into the taxonomy step by step, keeping the original names and meanings. Services share the groups, not identical codes.

AI​

QuestionAnswer
Is the "built for AI" angle proven?No. Stable names and explicit classification are expected to reduce ambiguity for AI tooling, but that is a hypothesis, not a benchmarked result.
Is the "built for AI" angle just marketing?The design is justified on ordinary engineering grounds first: consistency, exhaustive matching, explicit semantics. AI tooling benefits from the same properties, but the library stands on its own without it.