typescriptgenericsvariadic-tuple-types

How to compose validation predicates in order to create a function returning Ok | Error union?


I'm trying to write a function createValidation which composes predicates into final validator function. Here is usage example:

const validateString = createValidation(
  [(value: unknown): value is string => typeof value === 'string', 'Value is not a string'],
  [(value: string) => value.length > 0, 'String can not be empty'],
  [(value: string) => value.length < 10, 'String is long'],
);

createValidation accepts variadic number of [Predicate, string] tuples and returns a function which accepts value of first predicate's argument type and when called, checks validators one after another against this value until one of them returns false. In this case, function has to return { ok: false, error: string }. If all predicated passed, then it returns { ok: true, data: T } where T equals to type of last predicate's argument type.

Also, types of predicates have to be consistent, meaning the following should be a TS error:

const validateString = createValidation(
  [(value: unknown): value is string => typeof value === 'string', 'Value is not a string'],
  [(value: string) => value.length > 0, 'String can not be empty'],
  [(value: string) => value.length < 10, 'String is long'],
  [(value: number) => value === 123, 'A number?'],               // ERROR since 3rd predicate's arg is of type string
);

I tried to implement the function using variadic tuple types, but had no significant success.

Since, I suppose, some kind of composition is involved here and remembering that it is not trivial thing in TS, my question is is it even possible to achieve such kind of thing?


Solution

  • You want createValidation() to be a variadic function that accepts some number of pairs of validation functions and strings. You consider a validation function to be something of the form (value: X) => boolean or (value: X) => value is Y for suitable X and Y. That is, a validation function accepts a parameter and returns either a boolean or a type predicate.

    We can think of this as a "chain" of validation functions. At each link of the chain we can think of the "current data type" as the type a single value would be narrowed to if all the preceding validation functions return true. Each validation function must accept a parameter whose type is the current data type. If a validation function returns just boolean then the current data type is unaffected, but if a validation function returns a type predicate, the current data type is narrowed to the type from the type predicate.

    So if you have a validation chain like this:

    [
      (value: unknown) => value is string,
      (value: string) => boolean,
      (value: string) => value is "abc"
    ]
    

    then before the chain starts the current data type is the unknown type. The first validation function accepts unknown and narrows the current data type to string. The second validation function accepts string and doesn't narrow the current data type, so it's still string. The third validation function accepts string and narrows the current data type to the literal type "abc". So for the whole chain the current data type is the literal type "abc". Note that this was an acceptable chain because each validation function accepted a parameter of the current data type.

    You also want createValidation() to return a function which accepts a value of the argument type to the first validation function, and which returns a value that possibly has a data property whose type is the current data type of the whole chain.


    Given these requirements, you want createValidation() to be a generic function whose type parameter corresponds to the tuple type T of the validation chain. It would be great if we could write

    declare const createValidation:
      <T extends ValidationChain>
        (...vals: { [I in keyof T]: [T[I], string]
        ] }) => (value: ValidationChainInit<T>) =>
        { ok: true, data: ValidationChainData<T> } |
        { ok: false, error: string };
    

    where ValidationChain were some specific type that only allowed acceptable validation chains and where ValidationChainData<T> were the current data type for the chain T, and where ValidationChainInit<T> were the current data type for the first element of the chain T. Note how vals is a rest parameter of a mapped tuple type over T, so that vals is a list of validation-function and string pairs instead of just validation functions.

    Unfortunately there is no specific type ValidationChain in TypeScript that accepts good validation chains and rejects everything else. The closest we can come is a generic CheckValidationChain<T> type that acts like a constraint on T, so if T extends CheckValidationChain<T> then it's acceptable, otherwise it isn't.

    Then one could hope to write

    declare const createValidation:
      <T extends CheckValidationChain<T>>
        (...vals: { [I in keyof T]: [T[I], string]
        ] }) => (value: ValidationChainInit<T>) =>
        { ok: true, data: ValidationChainData<T> } |
        { ok: false, error: string };
    

    but alas T extends CheckValidationChain<T> is likely to be considered an illegally circular constraint. To avoid that we'd need to take CheckValidationChain<T> out of the constraint position, and instead put it inside the mapped type, so that each element T[I] is checked against CheckValidationChain<T>[I], like this:

    declare const createValidation:
      <T extends any[]>
        (...vals: { [I in keyof T]: [
          T[I] extends CheckValidationChain<T>[I] ? T[I] : CheckValidationChain<T>[I],
          string
        ] }) => (value: ValidationChainInit<T>) =>
        { ok: true, data: ValidationChainData<T> } |
        { ok: false, error: string };
    

    That conditional type T[I] extends CheckValidationChain<T>[I] ? T[I] : CheckValidationChain<T>[I] looks redundant, but what it's doing is allowing TypeScript to infer T[I] from the value at the Ith position of the vals argument, and then checking it against CheckValidationChain<T>[I] (the Ith element of the checked chain). If it is assignable, then everything is fine with that element and we can just keep it as T[I]. If not, then that element is incorrect and we require that element to be CheckValidationChain<T>[I] instead.

    It turns out we can do this, so now we need to define CheckValidationChain<T> and ValidationChainData<T> and ValidationChainInit<T>. Let's write them in terms of a more general validation-chain processor I'll call VChain<T>:

    type CheckValidationChain<T extends any[]> =
      VChain<T>["chain"] & Record<`${number}`, unknown>
    
    type ValidationChainData<T extends any[]> =
      VChain<T>["data"]
    
    type ValidationChainInit<T extends any[]> =
      VChain<T>["chain"][0] extends (val: infer I) => boolean ? I : unknown;
    

    The idea of VChain<T> is that it will take a validation chain T and produce an object type with two properties. A chain property will contain the validated version of T, and a data property will contain the current data type of the whole chain T. Like this:

    type VV = VChain<[
      (value: unknown) => value is string,
      (value: string) => boolean,
      (value: string) => value is "abc"
    ]>
    /* type VV = {
        chain: [
          (value: unknown) => value is string, 
          (value: string) => boolean, 
          (value: string) => value is "abc"
        ];
        data: "abc";
    } */
    

    Then CheckValidationChain<T> is mostly just indexing into VChain<T> with the "chain" property key (except we also intersect it with Record<`${number}`, unknown> so that TypeScript will let us index into CheckValidationChain<T> with any numeric string type (otherwise CheckValidationChain<T>[I] is likely to be an error). And ValidationChainData<T> is just indexing into VChain<T> with the "data" property key. And finally ValidationChainInit<T> gets the chain property of VChain<T>, looks at the first element, and grabs its parameter type.


    So finally we need to implement VChain<T>. Here it is:

    type VChain<T extends any[], A extends any[] = [], U = any> =
      T extends [infer F, ...infer R] ? (
        F extends ((val: U) => val is (infer V extends U)) ? VChain<R, [...A, F], V> :
        F extends ((val: U) => boolean) ? VChain<R, [...A, F], U> :
        { chain: [...A, (val: U) => boolean, ...any[]], data: U } // failure
      ) : { chain: A, data: U } // success  
    

    This is a tail-recursive conditional type which uses variadic tuple types to split T into its first element F and the rest of the tuple R. We check F and recurse on R. The current data type is represented by U (which we default to any), and the accumulated chain is represented by A.

    If F is a validation function that accepts U and returns a type predicate that narrows to V, then we recurse to VChain<R, [...A, F], V> (we accumulate F onto A, and narrow the current data type from U to V). If F is a validation function that accepts U and returns boolean then we recurse to VChain<R, [...A, F], U> (the same except we don't narrow the current data type).

    Otherwise F is not a proper validation function, so we give up recursing and return for the chain property [...A, (val: U) => boolean, ...any[]]. That is, we accumulate the required (val: U) => boolean type instead of whatever wrong thing F was. (And we say that the rest of the tuple doesn't matter by adding a rest element of type any[].) This will cause the caller of createValidation() to see an error on the offending element. And we return the current data type as the final data type for data (since there's no point continuing to process subsequent elements of the chain).

    Finally, if there is no F because T is the empty chain, we have successfully processed the whole thing. We return the accumulated chain A as the chain property, and the current data type U as the data property.


    Okay, let's try it out:

    const validateString = createValidation(
      [(value: unknown): value is string => typeof value === 'string', 'not a string'],
      [(value: string) => value.length > 0, 'String can not be empty'],
      [(value: string) => value.length < 10, 'String is long'],
    );
    /* const validateString: (value: unknown) => {
        ok: false;
        error: string;
    } | {
        ok: true;
        data: string;
    } */
    

    That worked; data is of type string, and there was no compiler error. Another test:

    const validateStringBad = createValidation(
      [(value: unknown): value is string => typeof value === 'string', 'not a string'],
      [(value: string) => value.length > 0, 'String can not be empty'],
      [(value: string) => value.length < 10, 'String is long'],
      [(value: number) => value === 123, 'A number?'], // error!
      //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      // Type '(value: number) => value is 123' is not assignable
      //   to type '(val: string) => boolean'.
      // Types of parameters 'value' and 'val' are incompatible.
      //     Type 'string' is not assignable to type 'number'.
    );
    /* const validateStringBad: (value: unknown) => {
        ok: false;
        error: string;
    } | {
        ok: true;
        data: string;
    } */
    

    Here we have an error on the offending validation function, which accepts a number when it needs to accept a string. One more:

    const validateNumber = createValidation(
      [(value: unknown): value is string | number => 
         typeof value === "string" || typeof value === "number", 
       "neither string nor number"],
      [(value: string | number): value is number => typeof value === "number", 
        "not a number"],
    );
    /* const validateNumber: (value: unknown) => {
        ok: false;
        error: string;
    } | {
        ok: true;
        data: number;
    } */
    

    That also worked; data is of type number, and there was no compiler error. So this is all behaving as desired. One more test, to check the "init" part:

    const validateAbc = createValidation([
      (val: string) => val === "abc", "not abc"]
    );
    /* const validateAbc: (value: string) => {
        ok: false;
        error: string;
    } | {
        ok: true;
        data: "abc";
    } */
    

    Looks good. The returned validateAbc function accepts a value of type string and not unknown.

    Playground link to code