Typescript route param validation function with type narrowing

50 Views Asked by At

I wanted to create a generic validation function for my api route params, however, I ran into an issue where the function doesn't narrow down the type after it's called.

This is the function:

export default function (
    param: QueryValue | QueryValue[],
    paramName: string,
    type: 'string' | 'number' | { [key: string]: unknown },
    rules: Array<(param: QueryValue | QueryValue[]) => { valid: boolean, errorMessage: string } | boolean> | null = null
): SuccessfulValidation | FailedValidation {

    // if the type is an enum
    if (typeof type === 'object' && type !== null) {
        if (!isEnumKey(type)(param)) {
            return {
                valid: false,
                error: createError({
                    statusCode: 400,
                    statusMessage: `Invalid "${paramName}" query parameter. The value must be one of the supported values.`,
                })
            }
        }
    } else if (['string'].includes(<string>type)) {
        if (typeof param !== type) {
            return {
                valid: false,
                error: createError({
                    statusCode: 400,
                    statusMessage: `Invalid "${paramName}" query parameter. The value must be a ${type}.`,
                })
            }
        }
    } else if (type === 'number') {
        if (typeof param !== 'string' || isNaN(parseInt(param))) {
            return {
                valid: false,
                error: createError({
                    statusCode: 400,
                    statusMessage: `Invalid "${paramName}" query parameter. The value must be a number.`,
                })
            }
        }
    } else if (typeof param !== typeof type) {
        return {
            valid: false,
            error: createError({
                statusCode: 400,
                statusMessage: `Invalid "${paramName}" query parameter. The value must be a ${typeof type}.`,
            })
        }
    }

    // if the rules are provided, validate the param against them
    if (rules) {
        for (const rule of rules) {
            const result = rule(param)

            if (typeof result === 'boolean') {
                if (!result) {
                    return {
                        valid: false,
                        error: createError({
                            statusCode: 400,
                            statusMessage: `Invalid "${paramName}" query parameter. Rule validation failed.`,
                        })
                    }
                }
            } else {
                if (!result.valid) {
                    return {
                        valid: false,
                        error: createError({
                            statusCode: 400,
                            statusMessage: result.errorMessage,
                        })
                    }
                }
            }

        }
    }

    return {
        valid: true,
        error: null,
    }
}

interface SuccessfulValidation {
    valid: true;
    error: null;
}

interface FailedValidation {
    valid: false;
    error: ReturnType<typeof createError>;
}

And this is an example useage:

const validation = routeParamValidate(queryOffset, 'offset', 'number')
if (!validation.valid) {
    return validation.error
}

// I would expect `queryOffset` to be typed as `number` here,
// but it stays as ` QueryValue | QueryValue[]`

I have this function, which does narrow down types:

export default function <T extends { [s: string]: unknown }>(e: T) {
    return (token: unknown): token is T[keyof T] => Object.values(e).includes(token as T[keyof T])
}

But I wasn't able to come up with a solution to the problem just from looking at this function alone. How could I improve the first one to make sure it narrows down the type? Also, what are some good resources to have a look at to get into more advanced typescript stuff like this? Most tutorials usually only really focus on the basics.

1

There are 1 best solutions below

0
Armand Biestro On

First of all, nice jobs, love this kind of idea! I add something like that in my other compony, so i'll try my best to help you to create an implementation but I dont have the code anymore

Abstract

we want a function that that a parameter (one or more queryValue) and the name of the parameter

we want to convert it an expected type and assert the fact that it is the proper type.

we want to be able to add rules to the validation for exemple if it is a boolean. but it is not mandatory.

problematic

So we want a function that looks like:

export ValidationType = 'string' | 'number' | { [key: string]: unknown }

export type CustomValidationRule = (value) => boolean

export type GlobalValidatationFunction(
    param: QueryValue | QueryValue[],
    paramName: string,
    type: ValidationType,
    rules?: CustomValidationRules[]
)

(re)typing

introducing generics

even if this type looks good, we could improve. We have better typings options.

for exemple, validationType, is it really a parameter by it self or is it just a rule? ?. It will be easier to narrow down the types and will be easier for setting up the rules. let's change the signatures:

export type QueryValue = string;
export type ValidationType = string | number | { [key: string]: unknown };

export type CustomValidationRule = (value: unknown) => boolean;

export function validate(
  param: QueryValue | QueryValue[],
  paramName: string,
  rules: CustomValidationRule[],
) {
  *****
}

doing it like this we will have a better way of adding new types and we wont need to change the signatures anymore!

but how are we going to check the type itself ? we will do that in the implemenation as a rule.

implementation

export type QueryValue = string;
export type ValidationType = string | number | { [key: string]: unknown };

export type CustomValidationRule = (value: unknown) => boolean;

export function validate(
  param: QueryValue | QueryValue[],
  paramName: string,
  rules: CustomValidationRule[],
) {
  let valueToCheck: QueryValue; // typed a string for me
  // param always as a string.
  // you implement that, I dont have the interface
  if (!isArray(param)) valueToCheck = param;
  else valueToCheck = '';
  const errors: string[] = [];
  rules.forEach((rule) => {
    if (!rule(valueToCheck)) {
      errors.push(`${paramName} is invalid`);
    }
  });
  return {
    valid: errors.length > 0,
    statusCode: errors.length > 0 ? 400 : 200,
    errors: errors.reduce((acc, error) => `${acc}\n${error}`) ?? '',
  };
};

validate('5616545', 'your take', [
  (t: unknown) => Number.isNaN(t),
  (t: unknown) => Number(t) > 0,
]);

hope this base will help

improvement

You could sign the CustomValidationRule like:

export type CustomValidationRule = (value: unknown) => {
  success: boolean,
  error: string,
};

So that you have a proper error handling

class validator

how ever, even if it fun and all, I would use the class validator

https://www.npmjs.com/package/class-validator

best typescript lesson for me

https://www.typescriptlang.org/docs/handbook/2/types-from-types.html

it will save you days, cheers