Goal
Add fromDiagnosis() so a diagnosis generator can be the single source of truth for both validation and a narrowing predicate.
Target usage:
const isTitle: TypeGuard<Title> = typeChecker(
narrow(isString, fromDiagnosis(diagnoseTitle)),
"Title",
);
Why the current implementation is insufficient
The three-argument form of narrow() intentionally separates a standard type predicate from diagnosis:
narrow(baseGuard, narrowingPredicate, diagnosis)
That separation preserves TypeScript interoperability and keeps the normal boolean path lightweight. However, domain constraints may then be written twice:
// Predicate
A && B && C
// Diagnosis
if (!A) yield issueA;
if (!B) yield issueB;
if (!C) yield issueC;
The two implementations can drift apart.
Primitive-specific builders such as stringType({ minLength, maxLength }) are not a good replacement. They introduce fixed public option-property names that cannot be mangled, special-case a few built-in types, and do not compose arbitrary existing TypeGuards.
Proposed solution
export const fromDiagnosis: <T, U extends T>(
diagnosis: (
value: T,
returnIssue?: NarrowingIssue,
) => Iterable<NarrowingIssue>,
) => NarrowingGuard<T, U>;
Example diagnosis:
function* diagnoseTitle(
value: string,
returnIssue?: NarrowingIssue,
): Iterable<NarrowingIssue> {
const length = Array.from(value).length;
if (length === 0) {
yield returnIssue ?? {
code: "too_short",
expected: "at least 1 code point",
};
}
if (100 < length) {
yield returnIssue ?? {
code: "too_long",
expected: "at most 100 code points",
};
}
}
Runtime behavior:
fromDiagnosis() returns false at the first yielded issue and true when the iterable is empty.
- Use a private shared
NarrowingIssue on the boolean path.
- Passing that issue makes
returnIssue ?? detailedIssue skip detailed issue construction, message formatting, and allocation.
- The checks and short-circuit point remain equivalent to a direct
A && B && C predicate. Generator/iterator overhead remains, but the validation work is the same.
Diagnostic metadata:
- Store the generated guard as the key and its diagnosis as the value in a private
WeakMap.
- Do not add a public property to the generated function.
narrow() should recognize this metadata.
- On structured-validation paths, call the stored diagnosis directly and do not run it once for the predicate and again for details.
Typing rules:
- A diagnosis accepting
T produces NarrowingGuard<T, U>.
U cannot be inferred from issue values; it comes from contextual typing or an explicit output type.
fromDiagnosis(diagnoseTitle) where diagnoseTitle accepts string cannot be passed directly to typeChecker(), because it does not accept unknown.
- Direct
typeChecker(fromDiagnosis(...)) is valid only when the diagnosis itself accepts unknown and handles the base-type check.
- The preferred compositional form remains
narrow(isString, fromDiagnosis(diagnoseTitle)).
Keep the existing narrow(base, predicate, diagnosis) form for performance-sensitive predicates or independently reusable predicate implementations.
Acceptance criteria
- The generated function has the public type
NarrowingGuard<T, U>.
- It can be used anywhere a standard narrowing predicate is accepted.
- A private
WeakMap preserves diagnosis without public function properties.
- The boolean path stops at the first issue and uses a shared
returnIssue.
- Detailed issue expressions are not evaluated when
returnIssue is supplied.
validate() and validateAll() return detailed issues without running diagnosis twice.
- Type tests cover contextual
U inference, missing context, and direct typeChecker() compatibility for unknown diagnoses.
- No primitive-specific constraint builder or option object is added.
Goal
Add
fromDiagnosis()so a diagnosis generator can be the single source of truth for both validation and a narrowing predicate.Target usage:
Why the current implementation is insufficient
The three-argument form of
narrow()intentionally separates a standard type predicate from diagnosis:That separation preserves TypeScript interoperability and keeps the normal boolean path lightweight. However, domain constraints may then be written twice:
The two implementations can drift apart.
Primitive-specific builders such as
stringType({ minLength, maxLength })are not a good replacement. They introduce fixed public option-property names that cannot be mangled, special-case a few built-in types, and do not compose arbitrary existing TypeGuards.Proposed solution
Example diagnosis:
Runtime behavior:
fromDiagnosis()returnsfalseat the first yielded issue andtruewhen the iterable is empty.NarrowingIssueon the boolean path.returnIssue ?? detailedIssueskip detailed issue construction, message formatting, and allocation.A && B && Cpredicate. Generator/iterator overhead remains, but the validation work is the same.Diagnostic metadata:
WeakMap.narrow()should recognize this metadata.Typing rules:
TproducesNarrowingGuard<T, U>.Ucannot be inferred from issue values; it comes from contextual typing or an explicit output type.fromDiagnosis(diagnoseTitle)wherediagnoseTitleacceptsstringcannot be passed directly totypeChecker(), because it does not acceptunknown.typeChecker(fromDiagnosis(...))is valid only when the diagnosis itself acceptsunknownand handles the base-type check.narrow(isString, fromDiagnosis(diagnoseTitle)).Keep the existing
narrow(base, predicate, diagnosis)form for performance-sensitive predicates or independently reusable predicate implementations.Acceptance criteria
NarrowingGuard<T, U>.WeakMappreserves diagnosis without public function properties.returnIssue.returnIssueis supplied.validate()andvalidateAll()return detailed issues without running diagnosis twice.Uinference, missing context, and directtypeChecker()compatibility forunknowndiagnoses.