honest-type
A type is a Set. A classifier is a pure function. Zero drift between static analysis, tests, and runtime.
import { z } from "zod"
const QuerySchema = z.object({
page: z.coerce.number().int().positive(),
sort: z.enum(["name","date","status"]),
order: z.enum(["asc","desc"]),
filter: z.enum(["active","archived"]),
})
// Every field needs an annotation.
// The schema is a class, not data.
import { vocabulary, binding, classify } from "honest-type"
const vocab = vocabulary({
page: (s) => /^\d+$/.test(s),
sort: new Set(["name","date","status"]),
order: new Set(["asc","desc"]),
filter: new Set(["active","archived"]),
})
// The type IS the Set.
// classify() returns named data, not an object.
const manifest = classify(tokens, vocab, bind)
from pydantic import BaseModel
from typing import Literal
class QueryParams(BaseModel):
page: int
sort: Literal["name","date","status"]
order: Literal["asc","desc"]
filter: Literal["active","archived"]
# Schema is a class.
# Types are annotations.
# Neither is plain data.
from honest_type import vocabulary, binding, classify
vocab = vocabulary({
"page": predicate(str.isdigit),
"sort": {"name", "date", "status"},
"order": {"asc", "desc"},
"filter": {"active", "archived"},
})
# The type IS the Set. Plain data.
# classify() returns a named dict.
manifest = classify(tokens, vocab, bind)
class QueryForm
include ActiveModel::Validations
SORT_VALUES = %w[name date status].freeze
ORDER_VALUES = %w[asc desc].freeze
validates :sort, inclusion: { in: SORT_VALUES }
validates :order, inclusion: { in: ORDER_VALUES }
# Validation is a class. Types are implicit.
# The Set is there but hidden inside the class.
end
$request->validate([
'page' => 'required|integer|min:1',
'sort' => 'required|in:name,date,status',
'order' => 'required|in:asc,desc',
'filter' => 'required|in:active,archived',
]);
// Validation rules are strings.
// The Sets are implicit in the rule syntax.
// Nothing is reusable across requests.
require "honest_type"
QUERY_VOCAB = HonestType.vocabulary(
sort: Set["name", "date", "status"],
order: Set["asc", "desc"],
filter: Set["active", "archived"],
page: ->(s) { s.match?(/\A\d+\z/) },
)
# The type IS the Set. Plain data.
# classify() returns a named hash.
manifest = HonestType.classify(tokens, QUERY_VOCAB, bind)
use HonestType\Vocabulary;
use HonestType\Classify;
$vocab = Vocabulary::make([
'sort' => ['name', 'date', 'status'],
'order' => ['asc', 'desc'],
'filter' => ['active', 'archived'],
'page' => fn($s) => ctype_digit($s),
]);
// The type IS the array. Plain data.
// classify() returns a named array.
$manifest = Classify::run($tokens, $vocab, $bind);
from django import forms
class QueryForm(forms.Form):
SORT_CHOICES = [
("name","Name"), ("date","Date"), ("status","Status")
]
sort = forms.ChoiceField(choices=SORT_CHOICES)
order = forms.ChoiceField(choices=[("asc","Asc"),("desc","Desc")])
page = forms.IntegerField(min_value=1)
# Validation is a class.
# The Sets are hidden inside ChoiceField.
# Nothing composes across forms.
from honest_type import vocabulary, classify, predicate
vocab = vocabulary({
"sort": {"name", "date", "status"},
"order": {"asc", "desc"},
"filter": {"active", "archived"},
"page": predicate(str.isdigit),
})
# The type IS the Set. Plain data.
# classify() returns a named dict.
manifest = classify(tokens, vocab, bind)
var validSort = map[string]bool{"name":true,"date":true,"status":true}
var validOrder = map[string]bool{"asc":true,"desc":true}
func validateQuery(sort, order, page string) error {
if !validSort[sort] {
return fmt.Errorf("invalid sort: %s", sort)
}
if !validOrder[order] {
return fmt.Errorf("invalid order: %s", order)
}
// Maps are there but validation is imperative.
// Returns an error, not named data.
}
vocab := honesttype.Vocabulary{
"sort": honesttype.Set{"name","date","status"},
"order": honesttype.Set{"asc","desc"},
"filter": honesttype.Set{"active","archived"},
"page": honesttype.Predicate(isDigits),
}
// The type IS the map. Plain data.
// Classify() returns a named map, not an error.
manifest, rejections := honesttype.Classify(tokens, vocab, bind)
# A type is a class or annotation.
# Validation is attached to the class.
# The vocabulary is implicit inside the schema object.
# Nothing is reusable as plain data.
class QueryParams:
sort: Literal["name","date","status"]
order: Literal["asc","desc"]
page: int
# A type IS a Set.
# The vocabulary is a plain dict of Sets.
# classify() is a pure function: tokens in, named dict out.
# The same vocabulary runs at static check, test, and runtime.
vocab = vocabulary({
"sort": {"name", "date", "status"},
"order": {"asc", "desc"},
"page": predicate(str.isdigit),
})
The concept
When an HTTP request arrives, you have a problem: the values are all strings. You need to know that sort is one of "name", "date", or "status" — not just any string. That page is a positive integer. That order is either "asc" or "desc".
Every framework solves this the same way: you declare a schema class, annotate the fields, and the framework validates incoming data against the class. The class owns the type definition. The framework owns the validation logic.
honest-type solves it differently. Instead of a class, you declare a plain dict. Instead of a framework validating it, a single function reads it.
vocab = vocabulary({
"sort": {"name", "date", "status"},
"order": {"asc", "desc"},
"filter": {"active", "archived"},
"page": predicate(str.isdigit),
})
manifest, rejections = classify(tokens, vocab, bind)
vocab is just a dict of field names to sets of valid values. classify is a function that takes the incoming tokens and returns a named dict — manifest — where every key maps to a validated value. Unrecognized values go into rejections. The function never throws.
classify() in one diagram:
tokens: ["name", "asc", "2"]
↓
classify(tokens, vocab, bind)
↓
manifest: {"sort": "name", "order": "asc", "page": "2"}
Tokens in. Named dict out. No raw string ever reaches business logic.
Why a dict instead of a class:
Consider what happens when you add "status" as a new valid sort value. With a schema class you update the annotation. With honest-type you add "status" to the set in vocab. Either way, one line.
The difference shows up elsewhere. Your test suite has a test that covers sort="name" and sort="date". Does it cover sort="status"? With a schema class you have to remember to add a test. With honest-type, you do not. honest-test reads vocab and runs every member of the set automatically. Add "status" to vocab and the test appears. Remove a value and the test disappears. The tests stay in sync with the vocabulary because they come from the vocabulary.
Same principle with the static checker. honest-check reads vocab and verifies that if a function downstream expects sort to be one of those three values, you cannot accidentally pass it something else. No type annotations needed. The checker reads the same dict the runtime reads.
The three checkpoints:
You write vocab once. Three tools read it:
- honest-check runs before your code deploys. It reads
vocaband checks that the values flowing between your functions are consistent. If you add a new sort value but forget to handle it in a downstream function, it tells you. - honest-test generates test cases from
vocab. For a vocabulary with 3 sort values, 2 order values, and 2 filter values, that is 12 combinations. All 12 run. You write zero test cases. - honest-type calls
classify()at runtime. Same dict, same rules.
Rejections are data, not exceptions:
If a token does not appear in its declared set, it goes into rejections. The caller decides what to do with it. classify() never throws. Inside the application, everything is a named dict.
The abstract principle
A type is a Set of values. Not a class. Not an annotation. Not a schema object. A Set.
This is not a metaphor. In set theory, a type is formally defined as the set of values that inhabit it. The type Boolean is the set {true, false}. The type Direction is the set {north, south, east, west}. The classifier that checks whether a value belongs to a type is set membership: value ∈ Type.
Every popular framework obscures this. A Pydantic class, a Zod schema, an ActiveModel validation — all of them are implementations of set membership wrapped in a class hierarchy. The set is still there. It is just hidden inside the object.
honest-type makes it explicit. The vocabulary is a dict of type names to Sets. The classifier is a pure function: tokens in, named dict out. There is no intermediate object. The set IS the type, directly, with no indirection.
The zero-drift guarantee
The same recognizer tables run at three times:
- Pre-commit (honest-check): set intersection detects type mismatches statically
- Test suite (honest-test): cartesian product of Set members generates exhaustive test cases
- Runtime (honest-type):
classify()runs the same membership check at every request boundary
Because all three use the same tables, there is no gap between what the static checker knows, what the tests exercise, and what the runtime enforces. This is the zero-drift property. It is only possible because the types are declared as finite Sets, not as classes or annotations.
A Pydantic model can have a zero-drift property if you are disciplined. honest-type makes it structural: the same dict literal is the vocabulary at all three checkpoints. Drift is architecturally impossible.
Why finiteness is the key property
Most type systems in widespread use today have unbounded type spaces. str in Python is infinite. string in TypeScript is infinite. A class hierarchy is infinite. You cannot exhaustively test an infinite type space. You can only sample it.
honest-type vocabularies are bounded by construction. A Set of five currency codes is not infinite. Its cartesian product with a Set of four format names is exactly 20 test cases. All 20 can run. All 20 must pass. The coverage is total, not probabilistic.
Predicates (for patterns like email addresses or UUIDs) handle the open-ended cases. For predicates, honest-test applies boundary testing: valid examples, adversarial near-misses, edge cases. Total coverage is not possible for predicates, but the bounded Set types — which cover the vast majority of real application vocabularies — are always total.
Rejections are data
An unrecognized token is not an error condition. It is a data value. The manifest carries a _rejections slot. The application decides what to do. This is the pure function property: the classifier has no side effects. It cannot throw. It cannot log. It cannot return control to the caller in an unexpected way. It returns a dict and that is all.
This eliminates an entire class of defensive programming. You do not need try/except around classification. You do not need if result.is_err() chains. You read manifest["_rejections"] if you care about them, and you do not if you do not.
Full specification
The classify() Algorithm
FUNCTION classify(tokens, vocabulary, binding):
manifest ← {}
rejections ← []
FOR EACH token IN tokens:
matched ← false
FOR EACH (type_name, recognizer) IN vocabulary:
IF recognize(token, recognizer):
slot ← binding.get(type_name, type_name)
manifest[slot] ← token
matched ← true
BREAK
IF NOT matched:
rejections.APPEND(token)
RETURN { **manifest, "_rejections": rejections }
recognize(token, recognizer) dispatches on recognizer type:
- Set: token IN recognizer
- Callable: recognizer(token) — must return bool, must not throw
- predicate(): evaluates the wrapped callable with the same rule
Token order does not affect the manifest when using flat binding. First match wins per token. A token matched to one type is not tested against others.
Vocabulary Construction Rules
A vocabulary is a plain dict of type names to recognizers. The vocabulary() constructor enforces:
- No reserved words. Type names may not be
_rejections,_faults, or any name beginning with_. - No catch-all recognizers. A recognizer that accepts more than 95% of a random sample of 1,000 strings is rejected at construction time with
HC011. - Recognizer overlap warning. If two Set recognizers share any member,
HC003is emitted. Two predicates that might overlap emitHC003as aninfo— static detection is not possible; honest-test verifies at runtime. - Vocabulary merge (
|).vocab_a | vocab_bproduces a new vocabulary. If the same type name appears in both, the right operand wins. Conflict emitsHC003warning.
Binding Rules
A binding is a plain dict of type names to slot names. Rules:
- Every type name in the binding must exist in the vocabulary — or
HC005is emitted. - A type with no binding entry uses the type name as its slot name.
- Multiple type names may bind to the same slot. Last match wins. This is intentional: it allows aliasing.
- Binding merges with
|the same way vocabularies do.
Composed Types
A composed type fires only when a specific context type has already matched in the same token list. Declared as:
composed(
name = "usd_amount",
requires = {"currency_code": "USD"},
captures = "decimal",
)
This type matches a decimal-looking token only when currency_code has already resolved to "USD" in the current manifest. Without currency_code: USD in the manifest, the recognizer is invisible to classify().
Composed types enable context-sensitive binding — the same token means different things in different contexts — without requiring tokens to appear in a specific order and without a third classification mechanism.
Maybe Types
A maybe type wraps any recognizer and adds Nothing as a valid value:
maybe("sort_direction") # matches {"asc","desc"} or the absence of any match
When the maybe slot's type is not found in the token list, the manifest receives None for that slot rather than leaving the slot absent. This makes optional parameters explicit in the manifest rather than requiring manifest.get() calls.
Chain Execution Model
A chain is an ordered list of links. Each link is a function annotated with accepts and emits vocabularies.
FUNCTION execute_chain(chain, manifest):
FOR EACH link IN chain.links:
result ← link(manifest)
IF result IS fault:
RETURN result ← short-circuit
manifest ← result
RETURN manifest
Short-circuit on fault. The first fault terminates the chain. No subsequent links execute. The fault propagates to @catch_at_boundary.
Manifest threading. Each link receives the full manifest from the previous link. Links accumulate: a link that adds keys to the manifest passes an enriched manifest to the next link. Links must not remove keys added by earlier links unless explicitly intended.
Boundary links. A link declared boundary=True may perform I/O. All other links must be pure. @catch_at_boundary is the outer boundary that converts faults to HTTP responses.
Fault Semantics
Faults are data, not exceptions. A fault is a dict:
{
"code": "unrecognized", # machine-readable
"category": "client", # "client" | "server"
"message": "...", # human-readable
"detail": {}, # optional additional data
}
category: "client" — the caller sent bad input. Maps to HTTP 400/422.
category: "server" — the system failed internally. Maps to HTTP 500.
@catch_at_boundary reads category and sets the HTTP status accordingly. Application code never writes return Response(status_code=422). It writes return fault("unrecognized", "client", ...).
Rejections from classify() are surfaced as fault("unrecognized", "client", ...) by @catch_at_boundary if the application does not handle them in the chain first.
Token Priority
When the same logical parameter arrives from multiple sources, priority order is:
_state(DOM state from domx, sent in request body)- Query parameters
- Path parameters
Higher priority wins on collision. This reflects specificity of intent: DOM state is the most specific because it was explicitly set by the user's current interaction.
Conformance Requirements
A conformant classify() implementation must satisfy all of the following:
| Requirement | Test |
|---|---|
| Same tokens + same vocab + same binding → same manifest | Run twice, compare |
| Token order does not affect manifest (flat binding only) | Shuffle tokens, compare |
Every token is either classified or in _rejections | No silent pass-through |
Unrecognized token produces _rejections entry, not exception | Test with unknown token |
Predicate that throws produces _faults entry, not exception | Test with throwing predicate |
Set recognizer: token IN set exact match, case-sensitive | Test "USD" vs "usd" |
vocabulary() raises on catch-all recognizer | Test with lambda s: True |
vocabulary() emits HC003 on overlapping Sets | Test shared member |
| Composed type fires only when context type present | Test with and without context |
Maybe type produces None when absent | Test with missing token |
| Chain short-circuits on first fault | Test fault in middle link |
| Boundary link may perform I/O; non-boundary link may not | honest-check HC-P004 |
@catch_at_boundary maps client fault to 400, server to 500 | Integration test |
Conformance Suite
The conformance suite lives at honest/honest-type-conformance/suite.json. Each test case provides a vocabulary definition, a token list, an optional binding, and the expected manifest. Implementations declare conformance level in their package metadata:
[tool.honest-type]
conformance = "Full"
conformance-suite-version = "1.0"
Core conformance: classify() algorithm, flat binding, rejection handling, Set and predicate recognizers.
Full conformance: Core + composed types + maybe types + chain execution + fault semantics + @catch_at_boundary.
Complete conformance: Full + honest-check HC rules + honest-test exhaustive harness.
Reference
- honest-type npm package JavaScript / TypeScript implementation
- honest-framework-spec.md Project 1: honest-type section
- honest-type-architecture.md Full language-agnostic spec