如何确保 Typescript 类型递归函数正确?

How to ensure Typescript types recursive functions correctly?

提问人:kohloth 提问时间:11/15/2023 最后编辑:kohloth 更新时间:11/15/2023 访问量:45

问:

我面临着一个非常棘手的打字稿问题。

我正在编写一个小型用户输入验证器库,用于验证可以无限嵌套的用户输入位。一段用户输入可以是布尔值、数字、字符串(基元)或数组或对象之一。

下面的测试显示了它是如何工作的(下面的链接将带您进入整个库的演示 - 除了此类型问题外,所有工作都正常工作)。

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);
});

全面实施在这里

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

此处还附上了最小示例

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: {}
};

正如我所说,实现功能很好。问题是我编写的机制(使用可区分的合并)不起作用。因此,这样的行会引发 TS 错误:

线:

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

错误:

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

这是因为 TS 认为验证结果始终是 3 种结果类型之一,而我希望 TS 根据我对文本成员和语句的使用来缩小范围。if

整个代码太长,无法在此处发布(但请参阅完整代码的链接),但我会发布我描述的部分以保持这篇文章的完整性:

    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.');
    }

我真正想避免的是不得不做这样的事情(这将在我使用验证器的许多地方重复)

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

不幸的是,由于验证遍历的无限递归和多管齐下的分支,我无法在调用时传递类型。

我唯一能想到的另一件事就是使用,但肯定有更好的方法!any

TypeScript 递归 推断类型

评论


答: 暂无答案