Skip to content

feat: add fromDiagnosis to derive narrowing guards from diagnoses #439

Description

@gjbkz

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions