Infer types in callback chain based on `null` return as success Indicator

Discussão em 'Angular' iniciado por Alessandro Caramia, Janeiro 4, 2025.

  1. I'm trying to type a callback chaining setup. I need to string together a series of functions where the types progressively update as the value flows through each one. Imagine a pipeline where each step can potentially narrow down the type based on specific conditions, particularly when a function returns null.

    Basic Example:

    I've got this myChainer function as a simplified demonstration of the chaining concept:

    function myChainer<T>(args: T) {
    return {
    map<U>(innerFunction: (payload: T) => U) {
    return myChainer<U>(innerFunction(args));
    }
    };
    }

    const myResult = myChainer(1)
    .map((x) => x + 1 + "")
    .map((x) => x.length)
    .map((x) => x > 8);


    The type inference works great at each map call here.

    My Specific Use Case: composeValidators

    What I'm actually trying to build is a composeValidators function. This function should accept a variable number of validation functions. The key here is that if a validation function returns null, it signifies that the validation passed.

    The tricky part is getting TypeScript to understand type narrowing based on these null returns. For instance, if the first validator confirms a value is not null, I'd love for subsequent validators to automatically infer the non-nullable type. The initial value could be of any type (string, number, etc.).

    Simple Validators:

    There's also a bit of added complexity in my setup: I have "simple validation functions" that just return a boolean. My field function handles these by converting a false return into a { simple: true } error object. I'm mentioning this because my example below includes these simple validators.

    Context:

    This is the field.ts file, responsible of the greater part of the logic and the code:

    import { computed, effect, Signal, signal } from "@angular/core";
    import { SignalField } from "../signal-forms/signal-field";
    import { ValidationErrors } from "@angular/forms";
    import { nullValidator } from "../signal-forms/validators";

    /**
    * Represents a computation function that takes an input value and returns a result.
    * @template TValue The type of the input value.
    * @param value The input value to compute from.
    * @returns The computed result.
    */
    export type SignalValidatorFn<TValue> = (value: TValue) => null | ValidationErrors;

    /**
    * Represents a simple validation function that takes an input value and returns a boolean.
    */
    export type SimpleValidatorFn<T> = (value: T) => boolean;

    /**
    * Checks if a given function is a simple validator function.
    * @param fn The function to check.
    * @param testValue The value to test the function with.
    * @returns True if the function is a simple validator, false otherwise.
    */
    function isSimpleValidator<T>(fn: SignalValidatorFn<T> | SimpleValidatorFn<T>, testValue: T): fn is SimpleValidatorFn<T> {
    // if the function returns a boolean, it is a simple validator
    return typeof fn(testValue) === 'boolean';
    }

    /**
    * Represents a single or multiple validation functions.
    */
    export type ValidatorFns<T> = SignalValidatorFn<T> | SimpleValidatorFn<T> | (SignalValidatorFn<T> | SimpleValidatorFn<T>)[];

    /**
    * Represents configuration options for a form field.
    * @template T The type of the field value.§
    */
    interface FieldOptions<T> {
    /**
    * Validation function or array of validation functions for the field.
    * Each function receives the field value and returns a boolean indicating if the value is valid.
    *
    * By default, the computation strategy is 'every' (AND operation).
    */
    valid: ValidatorFns<T>;

    /**
    * Disable condition function for the field.
    */
    disabled: SimpleValidatorFn<T>;
    }

    /**
    * The callback function for validation computation valuation.
    *
    * @typeParam T - The type of value contained in the SignalField
    * @param fieldValue - The SignalField value to validate
    * @param singleComputation - Single computation function or array of computation functions that take a value T and return boolean
    * @returns True if the computation passes, false otherwise
    */
    export function validationCallback<T>(
    fieldValue: T,
    singleComputation: SignalValidatorFn<T> | SimpleValidatorFn<T>
    ): null | ValidationErrors {
    if (isSimpleValidator(singleComputation, fieldValue)) {
    // simple validator function. return null if true, simple error object if false
    return singleComputation(fieldValue) === true ? null : { simple: true };
    } else {
    return singleComputation(fieldValue);
    }
    }

    /**
    * Creates a computed Signal<boolean> based on one or multiple computation functions applied to a SignalField.
    *
    * @typeParam T - The type of value contained in the SignalField
    *
    * @param signalField - The source SignalField to compute from
    * @param computation - Single computation function or array of computation functions that take a value V and return boolean
    * @param computationsStrategy - Strategy to combine multiple computations:
    * - 'some': returns true if any computation returns true
    * - 'every': returns true if all computations return true
    *
    * @returns A Signal<boolean> that updates whenever the source SignalField changes
    */
    function getValidationSignal<T>(
    signalField: SignalField<T>,
    computation: ValidatorFns<T>
    ): Signal<null | ValidationErrors> {
    // check if computation is an array
    if (Array.isArray(computation)) {
    // All computations must be null
    return computed(() => {
    // Return the first non-null result
    for (const c of computation) {
    const result = validationCallback(signalField(), c);

    // if any computation is not null, return the error
    if (result !== null) {
    return result;
    }
    }
    // if all computations are null, return null
    return null;
    });
    }

    // Single computation
    return computed(() => validationCallback(signalField(), computation));
    }

    /**
    * Creates a form field with built-in validation, state tracking, and disability management using Angular signals.
    *
    * @template T - The type of the field value
    * @param initialValue - The initial value of the field
    * @param fieldOptions - Configuration options for the field validation and disability
    * @returns A SignalField object
    *
    * @example
    * With validation:
    * ```typescript
    * const nameField = field('John', {
    * valid: (value) => value.length >= 3
    * });
    * ```
    */
    export function field<T>(
    initialValue: T | null,
    fieldOptions?: Partial<FieldOptions<T | null>>
    ): SignalField<T | null> {
    return createField(initialValue, fieldOptions);
    }

    field.required = function<T>(
    initialValue: T,
    fieldOptions?: Partial<FieldOptions<T>>
    ): SignalField<T> {
    return createField(initialValue, fieldOptions);
    }

    /**
    * Internal function that creates a form field with all necessary signals and methods.
    * This is the core implementation for both `field` and `field.required`.
    *
    * @template T - The type of the field value
    * @param initialValue - The initial value for the field
    * @param fieldOptions - Configuration options for field behavior
    *
    * @returns A fully configured SignalField instance with all associated signals and methods
    *
    * @internal
    * The function uses default computation strategies:
    * - For validation: 'every' (all validators must pass)
    */
    function createField<T>(
    initialValue: T,
    fieldOptions: Partial<FieldOptions<T>> = {
    valid: nullValidator,
    disabled: (): boolean => false
    }
    ): SignalField<T> {
    // init signal field
    const signalField = signal(initialValue) as SignalField<T>;

    // get valid computation function
    let validComputation = !fieldOptions.valid
    ? nullValidator
    : !Array.isArray(fieldOptions.valid)
    ? fieldOptions.valid
    : fieldOptions.valid;

    /**
    * Signal containing the computation function(s) for field validity.
    * Update this signal to change the field's validation logic.
    */
    const validComputationSignal = signal(validComputation);

    // set valid signal
    effect(() => {
    const validationSignal = getValidationSignal<T>(signalField, validComputationSignal());
    signalField.errors = computed(() => validationSignal());
    signalField.valid = computed(() => signalField.errors() === null);
    });

    // set other properties

    // return signal field
    return signalField;
    }


    validators.ts:

    import { ValidationErrors } from "@angular/forms";
    import { SignalValidatorFn, SimpleValidatorFn, validationCallback } from "./field";

    // other validators...

    /**
    * Validator that requires the control to have a non-empty, non-null value.
    * @param value The control value.
    * @returns `null` if the value is neither null nor undefined, `{ required: true }` otherwise.
    */
    export const requiredValidator: SignalValidatorFn<unknown> = <T>(value: T) => value ? null : { required: true };

    /**
    * No-op validator that always returns null.
    * Useful as a default validator or for testing purposes.
    * @param value The control value.
    * @returns Always returns `null`.
    */
    export const nullValidator: SignalValidatorFn<unknown> = <T>(value: T) => null;

    /**
    * Composes multiple validators into a single validator function.
    * Returns null only if all validators return null.
    * @param validators Array of validator functions to compose.
    * @returns A validator function that returns `null` if all validators pass, the first validation error otherwise.
    */
    export function composeValidators<T>(...validators: (SignalValidatorFn<T> | SimpleValidatorFn<T>)[]): SignalValidatorFn<T> {
    return (value: T) => {
    return validators.reduce((acc: null | ValidationErrors, validator: SignalValidatorFn<T> | SimpleValidatorFn<T>) => {
    if (acc !== null) {
    return acc;
    }

    const validationResult = validationCallback(value, validator);
    return validationResult === null
    ? null
    : validationResult;
    }, null);
    }
    }


    Current Implementation (and the Problem):

    Here's how I'm currently using composeValidators:

    @Component({
    selector: 'app-example-form-page',
    templateUrl: './example-form-page.component.html'
    })
    export class ExampleFormPageComponent {
    lastName = field<string>(null, {
    valid: composeValidators<string | null>(
    requiredValidator,
    value => !!value && value.length > 0 ? null : { error: true },
    value => !!value && value.length < 10,
    value => value !== "test"
    )
    });
    }


    It works, but as you can see with the lastName field, I have to explicitly check !!value in every validation function, even after the requiredValidator which guarantees the value isn't null.

    My Ideal Scenario:

    Here's what I'd love to achieve:

    ...
    export class ExampleFormPageComponent {
    lastName = field<string>(null, {
    valid: composeValidators( // Ideally, the type could even narrow from the field definition
    requiredValidator,
    value => value.length > 0 ? null : { error: true }, // Type inferred as 'string' here!
    value => value.length < 10,
    value => value !== "test"
    )
    });
    }


    It gets a bit trickier because the type narrowing depends on a condition that leads to a null result (like value ? null : { required: true }). I need the type inference to "kick in" specifically when a validator function returns null. I want to write my form field APIs with this kind of simplicity.

    I'm looking for a solution that provides killer type inference after each validator, especially when null is returned.

    Continue reading...

Compartilhe esta Página