How to ensure Typescript types recursive functions correctly?

62 views Asked by At

I am facing a very tricky Typescript problem.

I am writing a small user-input validator library that validates bits of user-input that can be infinitely nested. A piece of user input can be one of either boolean, number, string (primitive) or array or object.

The test below shows how it works (and the link below takes you to a demo of the whole library - all working, except for this types problem).

it('validates an invalid array of objs of prims correctly', () => {
    const value = [
        { name: 'adam' },
        { name: 'jade' },
        { name: 's' },
    ];
    const descriptor: ValidatorArrayFieldDescriptor = {
        required: true,
        type: 'array',
        children: {
            type: 'object',
            children: {
                name: {
                    type: 'string',
                    rules: [
                        { type: 'min', params: { size: 2 } }
                    ],
                },
            },
        },
    };
    const result = validator.checkValue({
        value,
        descriptor,
    });
    expect(result.messages.length).toBe(0);
    expect(result.children[0].children.name.messages.length).toBe(0);
    expect(result.children[0].children.name.isValid).toBe(true);
    expect(result.children[1].children.name.messages.length).toBe(0);
    expect(result.children[1].children.name.isValid).toBe(true);
    expect(result.children[2].children.name.messages.length).toBe(1);
    expect(result.children[2].children.name.isValid).toBe(false);
    expect(result.isValid).toBe(false);
    expect(result.selfIsValid).toBe(true);
    expect(result.childrenAreValid).toBe(false);
});

Full implementation is here

https://codesandbox.io/s/snowy-forest-2wcxjz?file=/src/validator.ts

Minimal example also attached here

import { startCase } from "lodash";

// ############################################################################
// Utils
// ############################################################################

function formatPropKey(rawName: string): string {
  return utilFns.ucFirst(startCase(rawName).toLowerCase());
}

function isObject(obj: any) {
  if (typeof obj === "boolean") return false;
  if (typeof obj === "number") return false;
  if (typeof obj === "string") return false;
  if (typeof obj === "undefined") return false;
  if (Array.isArray(obj)) return false;
  let out = false;
  try {
    Object.keys(obj);
  } catch (err) {
    return false;
  }
  return true;
}

function ucFirst(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function ensureArray<T = any>(arr: any): T[] {
  if (Array.isArray(arr)) return arr as T[];
  if (!arr) return [] as T[];
  return [arr] as T[];
}

const utilFns = {
  isObject,
  ucFirst,
  ensureArray
};


// ############################################################################
// Types
// ############################################################################

export type ValidatorRuleType =
  | "min"
  | "max"
  | "pattern"

// ====

export interface ValidatorRuleMinParams {
  size: number;
}

// ====

export interface ValidatorRuleMaxParams {
  size: number;
}

// ====

export interface ValidatorRulePatternParams {
  pattern: RegExp;
}

// ===

export interface ValidatorTagValidityInfo {
  subMessage: string;
}

// ===

export interface ValidatorTagsValidityInfo {
  subMessage: string;
  plural: boolean;
}

// ===

export interface ValidatorBlackperiodsValidityInfo {
  subMessage: string;
  plural: boolean;
}

// ====

export type AnyValidatorRuleParams =
  | ValidatorRuleMinParams
  | ValidatorRuleMaxParams
  | ValidatorRulePatternParams
  | ValidatorRuleOneOfParams;
export type AnyValidityInfo =
  | ValidatorTagValidityInfo
  | ValidatorTagsValidityInfo
  | ValidatorBlackperiodsValidityInfo;

// ============================================================================

export interface BuiltInValidatorParams<
  T = AnyValidatorRuleParams | undefined
> {
  value: any;
  dataType: ValidatorFieldDataType;
  ruleParams: T;
}

export interface BuiltInValidatorMessageGetterParams<
  RuleParams = AnyValidatorRuleParams | undefined,
  ValidityInfo = AnyValidityInfo | undefined
> {
  propKey: string;
  dataType: ValidatorFieldDataType;
  ruleParams: RuleParams;
  validityInfo: ValidityInfo;
}

// ============================================================================

export type ValidatorFieldDataType =
  | "string"
  | "boolean"
  | "number"
  | "array"
  | "object";

// ============================================================================

export interface ValidatorFieldRule {
  type: ValidatorRuleType;
  params: any;
}

export interface ValidatorBooleanFieldDescriptor {
  type: "boolean";
  required?: boolean;
  rules?: ValidatorFieldRule[];
}

export interface ValidatorNumberFieldDescriptor {
  type: "number";
  required?: boolean;
  rules?: ValidatorFieldRule[];
}

export interface ValidatorStringFieldDescriptor {
  type: "string";
  required?: boolean;
  rules?: ValidatorFieldRule[];
}

export interface ValidatorArrayFieldDescriptor {
  type: "array";
  required?: boolean;
  rules?: ValidatorFieldRule[];
  children: AnyValidatorFieldDescriptor;
}

export interface ValidatorObjectFieldDescriptor {
  type: "object";
  required?: boolean;
  rules?: ValidatorFieldRule[];
  children: { [key: string]: AnyValidatorFieldDescriptor };
}

export type AnyValidatorFieldDescriptor =
  | ValidatorBooleanFieldDescriptor
  | ValidatorNumberFieldDescriptor
  | ValidatorStringFieldDescriptor
  | ValidatorArrayFieldDescriptor
  | ValidatorObjectFieldDescriptor;

export type PickValidatorFieldDescriptor<
  T extends ValidatorFieldDataType
> = T extends "boolean"
  ? ValidatorBooleanFieldDescriptor
  : T extends "number"
  ? ValidatorNumberFieldDescriptor
  : T extends "string"
  ? ValidatorStringFieldDescriptor
  : T extends "array"
  ? ValidatorArrayFieldDescriptor
  : T extends "object"
  ? ValidatorObjectFieldDescriptor
  : never;

// ============================================================================

export interface ValidatorFieldPrimativeResult {
  isValid: boolean;
  messages: string[];
}

export interface ValidatorFieldArrayResult {
  isValid: boolean;
  messages: string[];
  selfIsValid: boolean;
  childrenAreValid: boolean;
  children: ValidatorFieldPrimativeResult[];
}

export interface ValidatorFieldObjectResult {
  isValid: boolean;
  messages: string[];
  selfIsValid: boolean;
  childrenAreValid: boolean;
  children: {
    [key: string]: ValidatorFieldPrimativeResult;
  };
}

export type PickValidatorFieldResult<
  T extends ValidatorFieldDataType
> = T extends "boolean"
  ? ValidatorFieldPrimativeResult
  : T extends "number"
  ? ValidatorFieldPrimativeResult
  : T extends "string"
  ? ValidatorFieldPrimativeResult
  : T extends "array"
  ? ValidatorFieldArrayResult
  : T extends "object"
  ? ValidatorFieldObjectResult
  : never;

export type AnyValidatorFieldResult =
  | ValidatorFieldPrimativeResult
  | ValidatorFieldArrayResult
  | ValidatorFieldObjectResult;

// ############################################################################
// Built in system checks
// ############################################################################

export const builtInSystemChecks = {
  required: (propKey: string) => {
    return `${formatPropKey(propKey)} must be supplied.`;
  },
  incorrectFormat: (propKey: string, expected: string, got: string) => {
    return `${formatPropKey(
      propKey
    )} should be a ${expected}, but as a ${got}.`;
  }
};

// ############################################################################
// Built-in validators
// ############################################################################

export const builtInValidators: {
  [key: string]: {
    message: (params: any) => string;
    fn: (
      params: any
    ) => {
      valid: boolean;
      validityInfo?: any;
    };
  };
} = {
  min: {
    message: (
      params: BuiltInValidatorMessageGetterParams<ValidatorRuleMinParams>
    ) => {
      const { propKey, dataType, ruleParams } = params;
      const { size } = ruleParams;
      if (dataType === "string") {
        return `${formatPropKey(
          propKey
        )} must have no fewer than ${size} characters`;
      } else if (dataType === "number") {
        return `${formatPropKey(propKey)} must be no smaller than ${size}`;
      } else if (dataType === "array" || dataType === "object") {
        return `${formatPropKey(
          propKey
        )} must have no fewer than ${size} items`;
      } else {
        throw new Error(
          `The rule "min" cannot be used on a datatype of "${dataType}".`
        );
      }
    },
    fn: (params: BuiltInValidatorParams<ValidatorRuleMinParams>) => {
      const { value, dataType, ruleParams } = params;
      const { size } = ruleParams;
      const sizeValue = (() => {
        if (dataType === "string") {
          return value.length;
        } else if (dataType === "number") {
          return value;
        } else if (dataType === "array") {
          return value.length;
        } else if (dataType === "object") {
          return Object.keys(value).length;
        } else {
          throw new Error(
            `The rule "min" cannot be used on a datatype of "${dataType}".`
          );
        }
      })();
      const givenSize = Number.parseInt(sizeValue, 10) || 0;
      return { valid: givenSize >= size };
    }
  },
  max: {
    message: (
      params: BuiltInValidatorMessageGetterParams<ValidatorRuleMaxParams>
    ) => {
      const { propKey, dataType, ruleParams } = params;
      const { size } = ruleParams;
      if (dataType === "string") {
        return `${formatPropKey(
          propKey
        )} must have no fewer than ${size} characters`;
      } else if (dataType === "number") {
        return `${formatPropKey(propKey)} must be no smaller than ${size}`;
      } else if (dataType === "array" || dataType === "object") {
        return `${formatPropKey(
          propKey
        )} must have no fewer than ${size} items`;
      } else {
        throw new Error(
          `The rule "max" cannot be used on a datatype of "${dataType}".`
        );
      }
    },
    fn: (params: BuiltInValidatorParams<ValidatorRuleMinParams>) => {
      const { value, dataType, ruleParams } = params;
      const { size } = ruleParams;
      const sizeValue = (() => {
        if (dataType === "string") {
          return value.length;
        } else if (dataType === "number") {
          return value;
        } else if (dataType === "array") {
          return value.length;
        } else if (dataType === "object") {
          return Object.keys(value).length;
        } else {
          throw new Error(
            `The rule "max" cannot be used on a datatype of "${dataType}".`
          );
        }
      })();
      const givenSize = Number.parseInt(sizeValue, 10) || 0;
      return { valid: givenSize <= size };
    }
  },
  pattern: {
    message: (
      params: BuiltInValidatorMessageGetterParams<ValidatorRulePatternParams>
    ) => {
      const { propKey, dataType, ruleParams } = params;
      const { pattern } = ruleParams;
      return `${formatPropKey(
        propKey
      )} must match the pattern "${pattern.toString()}"`;
    },
    fn: (params: BuiltInValidatorParams<ValidatorRulePatternParams>) => {
      const { value, dataType, ruleParams } = params;
      const { pattern } = ruleParams;
      return { valid: pattern.test(value || "") };
    }
  },
};

// ############################################################################
// Check value
// ############################################################################

interface CheckSingleValueProps<T extends ValidatorFieldDataType> {
  value: any;
  descriptor: PickValidatorFieldDescriptor<T>;
  propKey?: string;
  validationEnabled?: boolean;
  auxData?: any;
}

function checkValue<T extends ValidatorFieldDataType>({
  value,
  descriptor,
  propKey = "thisValue",
  validationEnabled,
  auxData
}: CheckSingleValueProps<T>): PickValidatorFieldResult<T> {
  const isBoolean = typeof value === "boolean";
  const hasBoolean =
    descriptor.type === "boolean" && (value === false || value === true);

  const isString = typeof value === "string";
  const hasString =
    descriptor.type === "string" &&
    typeof value === "string" &&
    value.length > 0;

  const isNumber = typeof value === "number";
  const hasNumber = descriptor.type === "number" && typeof value === "number";

  const isArray = Array.isArray(value);
  const hasArray = descriptor.type === "array" && Array.isArray(value);

  const isObject = utilFns.isObject(value);
  const hasObject = descriptor.type === "object" && utilFns.isObject(value);

  const isPrimitive = isBoolean || isString || isNumber;
  const hasValue =
    hasBoolean || hasString || hasNumber || hasArray || hasObject;

  if (descriptor.type === "boolean") {
    if (descriptor.required && !hasBoolean) {
      const out: ValidatorFieldPrimativeResult = {
        messages: [builtInSystemChecks.required(propKey)],
        isValid: false
      };
      return out as PickValidatorFieldResult<T>;
    }
    if (!isBoolean) {
      const out: ValidatorFieldPrimativeResult = {
        messages: [
          builtInSystemChecks.incorrectFormat(propKey, "boolean", typeof value)
        ],
        isValid: false
      };
      return out as PickValidatorFieldResult<T>;
    }
  }

  if (descriptor.type === "string") {
    if (descriptor.required && !hasString) {
      const out: ValidatorFieldPrimativeResult = {
        messages: [builtInSystemChecks.required(propKey)],
        isValid: false
      };
      return out as PickValidatorFieldResult<T>;
    }
    if (!isString) {
      const out: ValidatorFieldPrimativeResult = {
        messages: [
          builtInSystemChecks.incorrectFormat(propKey, "string", typeof value)
        ],
        isValid: false
      };
      return out as PickValidatorFieldResult<T>;
    }
  }

  if (descriptor.type === "number") {
    if (descriptor.required && !hasNumber) {
      const out: ValidatorFieldPrimativeResult = {
        messages: [builtInSystemChecks.required(propKey)],
        isValid: false
      };
      return out as PickValidatorFieldResult<T>;
    }
    if (!isNumber) {
      const out: ValidatorFieldPrimativeResult = {
        messages: [
          builtInSystemChecks.incorrectFormat(propKey, "number", typeof value)
        ],
        isValid: false
      };
      return out as PickValidatorFieldResult<T>;
    }
  }

  if (descriptor.type === "array") {
    if (descriptor.required && !hasArray) {
      const out: ValidatorFieldArrayResult = {
        messages: [builtInSystemChecks.required(propKey)],
        isValid: false,
        selfIsValid: false,
        childrenAreValid: true,
        children: []
      };
      return out as PickValidatorFieldResult<T>;
    }
    if (!isArray) {
      const out: ValidatorFieldArrayResult = {
        messages: [
          builtInSystemChecks.incorrectFormat(propKey, "array", typeof value)
        ],
        isValid: false,
        selfIsValid: false,
        childrenAreValid: true,
        children: []
      };
      return out as PickValidatorFieldResult<T>;
    }
  }

  if (descriptor.type === "object") {
    if (descriptor.required && !hasObject) {
      const out: ValidatorFieldObjectResult = {
        messages: [builtInSystemChecks.required(propKey)],
        isValid: false,
        selfIsValid: false,
        childrenAreValid: true,
        children: {}
      };
      return out as PickValidatorFieldResult<T>;
    }
    if (!isObject) {
      const out: ValidatorFieldObjectResult = {
        messages: [
          builtInSystemChecks.incorrectFormat(propKey, "object", typeof value)
        ],
        isValid: false,
        selfIsValid: false,
        childrenAreValid: true,
        children: {}
      };
      return out as PickValidatorFieldResult<T>;
    }
  }

  function mapRules(rules: any, output: any) {
    utilFns.ensureArray(rules).forEach((rule) => {
      const result = builtInValidators[rule.type].fn({
        value,
        dataType: descriptor.type,
        ruleParams: rule.params
      });
      if (!result.valid) {
        const message = builtInValidators[rule.type].message({
          propKey,
          dataType: descriptor.type,
          ruleParams: rule.params,
          validityInfo: result.validityInfo
        });
        output.messages.push(message);
        output.isValid = false;
      }
    });
  }

  if (
    descriptor.type === "boolean" ||
    descriptor.type === "number" ||
    descriptor.type === "string"
  ) {
    const output: ValidatorFieldPrimativeResult = {
      messages: [],
      isValid: true
    };
    if (hasValue) {
      mapRules(descriptor.rules, output);
    }
    return output as PickValidatorFieldResult<T>;
  } else if (descriptor.type === "array") {
    const output: ValidatorFieldArrayResult = {
      isValid: true,
      selfIsValid: true,
      childrenAreValid: true,
      messages: [],
      children: []
    };
    if (hasValue) {
      mapRules(descriptor.rules, output);
      if (!output.isValid) output.selfIsValid = false;
      output.children = value.map((childValue: any) => {
        const result = checkValue({
          value: childValue,
          descriptor: descriptor.children
        });
        if (!result.isValid) {
          output.childrenAreValid = false;
          output.isValid = false;
        }
        return result;
      });
    }
    return output as PickValidatorFieldResult<T>;
  } else if (descriptor.type === "object") {
    const output: ValidatorFieldObjectResult = {
      isValid: true,
      selfIsValid: true,
      childrenAreValid: true,
      messages: [],
      children: {}
    };
    if (hasValue) {
      mapRules(descriptor.rules, output);
      if (!output.isValid) output.selfIsValid = false;
      output.children = Object.fromEntries(
        Object.entries(descriptor.children).map((ruleEntry) => {
          const [childKey, childDescriptor] = ruleEntry as [
            string,
            AnyValidatorFieldDescriptor
          ];
          const childValue = value[childKey];
          const result = checkValue({
            value: childValue,
            descriptor: childDescriptor,
            propKey: childKey
          });
          if (!result.isValid) {
            output.childrenAreValid = false;
            output.isValid = false;
          }
          return [childKey, result];
        })
      );
    }
    return output as PickValidatorFieldResult<T>;
  } else {
    throw new Error("Value was not primitive, array, or object.");
  }

  throw new Error(`Invalid descriptor type: "${descriptor.type}"`);
}

// ############################################################################
// Validate one record
// ############################################################################

// function checkRecord<Rec, Rules>({
//  record,
//  rules,
//  auxData,
//  validationEnabled = true,
// }: UseValidatedRecordProps): UseValidatedRecordData {

// }

// ############################################################################
// Validate many records
// ############################################################################

// function checkRecords<Rec, Rules>({
//  records,
//  rules,
//  auxData,
//  validationEnabled,
// }: UseValidatedRecordsProps): UseValidatedRecordsData {

// }

// ############################################################################
// Output
// ############################################################################

export const validator = {
  bivs: builtInValidators,
  biscs: builtInSystemChecks,
  checkValue,
  utils: {}
};

As I say, the implemenation functions fine. The problem is that the mechanism I've written (that uses discriminated unions) is not functioning. Therefore, lines such as this raise TS errors:

Line:

expect(result.children[1].children.name.isValid).toBe(true)

Error:

Property 'children' does not exist on type 'ValidatorFieldPrimativeResult | ValidatorFieldArrayResult | ValidatorFieldObjectResult'.

This is because TS thinks that the validation result is always one of 3 result types, when I expect TS to narrow it down based on my use of literal members and if statements.

The whole code is too long to post here (but please see link for the full code) but I will post the section I describe to keep this post complete:

    if (descriptor.type === 'boolean' || descriptor.type === 'number' || descriptor.type === 'string') {
        const output: ValidatorFieldPrimativeResult = {
            messages: [],
            isValid: true,
        };
        if (hasValue) {
            mapRules(descriptor.rules, output);
        }
        return output as PickValidatorFieldResult<T>;
    } else if (descriptor.type === 'array') {
        const output: ValidatorFieldArrayResult = {
            isValid: true,
            selfIsValid: true,
            childrenAreValid: true,
            messages: [],
            children: [],
        };
        if (hasValue) {
            mapRules(descriptor.rules, output);
            if (!output.isValid) output.selfIsValid = false;
            output.children = value.map((childValue: any) => {
                const result = checkValue({
                    value: childValue,
                    descriptor: descriptor.children,
                });
                if (!result.isValid) {
                    output.childrenAreValid = false;
                    output.isValid = false;
                }
                return result;
            });
        }
        return output as PickValidatorFieldResult<T>;
    } else if (descriptor.type === 'object') {
        const output: ValidatorFieldObjectResult = {
            isValid: true,
            selfIsValid: true,
            childrenAreValid: true,
            messages: [],
            children: {},
        }
        if (hasValue) {
            mapRules(descriptor.rules, output);
            if (!output.isValid) output.selfIsValid = false;
            output.children = Object.fromEntries(
                Object.entries(descriptor.children).map(ruleEntry => {
                    const [childKey, childDescriptor] = ruleEntry as [string, AnyValidatorFieldDescriptor];
                    const childValue = value[childKey];
                    const result = checkValue({
                        value: childValue,
                        descriptor: childDescriptor,
                        propKey: childKey,
                    });
                    if (!result.isValid) {
                        output.childrenAreValid = false;
                        output.isValid = false;
                    }
                    return [childKey, result];
                })
            );
        }
        return output as PickValidatorFieldResult<T>;
    } else {
        throw new Error('Value was not primitive, array, or object.');
    }

What I really want to avoid is having to do something like this (which will be repeated in many places where I am using the validator)

(((result as ValidatorFieldArrayResult).children[0] as ValidatorFieldObjectResult).children.name as ValidatorFieldPrimativeResult).messages.length

Unfortunately I can't pass the types in a call-time due to the possibly infinite recursion and many-pronged branching of the validation walk.

Only other thing I can think of doing is using any, but surely there is a better way!

0

There are 0 answers