Nuubles
Nuubles

Reputation: 187

How to differentiate between function types in TypeScript

I need to create type guards that are nested, and as such, they require either a function that returns the result of type checking isOk(data) => boolean, or a function that returns a function that returns the result of type checking isOk()(data) => boolean. This has to be done to allow the type checking to be nested and reference to either itself it two objects to each other.

This has been implemented all well and good, but no matter what I try I can't seem to find out how to differentiate between those two function types if it is given as an argument.

For example, exporting this is not possible as typescript prevents exporting values with the same name.

Minimum reproducible 1

export type ValidatorFunction = (data: unknown) => boolean;
export type ValidatorRetriever = () => ValidatorFunction;

export function isType(validator: ValidatorRetriever, data: unknown): boolean {
    return validator()(data);
}

export function isType(validator: ValidatorFunction, data: unknown): boolean {
    return validator(data);
}

similarly using instanceof is not possible, as it is used only for classes and not types, as types are discarded during transpile.

Minimum reproducible 2

export type ValidatorFunction = (data: unknown) => boolean;
export type ValidatorRetriever = () => ValidatorFunction;

export function isType(validator: ValidatorRetriever | ValidatorFunction, data: unknown): boolean {
    if(validator instanceof ValidatorRetriever) {
        return validator()(data);
    } else if(validator instanceof ValidatorFunction) {
        return validator(data);
    }
    return false;
}

Either the same function name or type detection must exist, as some objects may require either one or both of different validation functions, such as

let object2Validator = undefined;

const object1Validator = object1({
  key: isString, // which would be ValidatorFunction
  children: isArray(() => object2Validator) // which would be ValidatorRetriever
});
object2Validator = object2({
  path: isString,
  children: isArray(object1Validator)
});

if(object2Validator({...})) {
  console.log("Is object 2");
} else {
  console.log("Is not object 2");
}

The code examples are very barebones, but the main problem is the following: how to make either one of the previous two minimum reproducibles work?

A. export functions with the same name but different parameters (like isArray in the example)

Minimum reproducible 1 from above

or

B. differentiate between two different function types ValidatorFunction and ValidatorRetriever (alternative to different function implementations)

Minimum reproducible 2 from above

Upvotes: 3

Views: 541

Answers (1)

jcalz
jcalz

Reputation: 329598

Neither JavaScript nor TypeScript have "true" function overloads in the sense that you can have two different functions with the same name that differ by number/types of argument. TypeScript's overloads allow you to have multiple call signatures, but there's still just one function implementation, and that implementation needs to be written in such a way that it can handle all of the possible call signatures. it is not possible to "export functions with the same name but different parameters".


That means we need to try to write a single isType() function that can somehow differentiate between a ValidatorRetriever input and a ValidatorFunction input at runtime. If you can figure out a way to do this, then you can write a user-defined type guard function to let the TypeScript compiler know that's what you're doing. So it would look like this:

function isRetriever(v: ValidatorRetriever | ValidatorFunction): v is ValidatorRetriever {
    /* implement this somehow */
}

The isRetriever() function takes an input that is either a ValidatorRetriever or a ValidatorFunction and returns a boolean value. If it returns true then the compiler knows that the input is actually a ValidatorRetriever; otherwise if it returns false then the compiler knows that the input is actually a ValidatorFunction. This lets you write isType() as follows:

function isType(validator: ValidatorRetriever | ValidatorFunction, data: unknown): boolean {
    return isRetriever(validator) ? validator()(data) : validator(data)
}

So the question now is... how do we implement isRetriever()?


Unfortunately there's really no proper way to do this. JavaScript has a weak type system. You can't ask JavaScript about the parameter or return types of a function without calling it. There is a Function.length property which tells you the number of arguments that a function expects. So potentially you could just check whether v.length is 0 (for a ValidatorRetriever) or 1 (for a ValidatorFunction). And indeed this will work for simple cases:

function isRetriever(v: ValidatorRetriever | ValidatorFunction): v is ValidatorRetriever {
    return v.length === 0; 
}

function str() {
    return (x: unknown) => typeof x === "string"
}
console.log(isType(str, "abc")) // okay, true

function num(x: unknown) {
    return typeof x === "number"
}
console.log(isType(num, "abc")) // okay, false

But you can't rely on the length property this way. Functions in JavaScript can have rest parameters and accept any number of arguments, but this does not contribute to length:

function oopsieDoodle(...args: any[]) {
    return args.length === 0
}

console.log(isType(oopsieDoodle, "abc")) // 💥 RUNTIME ERROR! validator() is not a function

The oopsieDoodle() function is a valid ValidatorFunction, since it does accept a single unknown argument (you can call oopsieDoodle(x) where x is of type unknown. Function type compatibility can sometimes be surprising) and returns a boolean. But our isRetriever() implementation erroneously decides that it's a ValidatorRetriever because the length property is 0. And we get a runtime error.

Maybe this isn't a big deal to you, and you'll accept that some inputs to isType() will cause runtime errors. If it is a big deal then you quickly run into unpleasant ways of trying to deal with it. Like, maybe you can test it by calling it and then seeing if the return type is a boolean or a function?

function isRetriever(v: ValidatorRetriever | ValidatorFunction): v is ValidatorRetriever {
    return typeof v("TESTING") === "function"; 
}

This should work as long as v doesn't actually explode if you pass it an argument when it's not expecting one. And as long as calling it doesn't have unintended consequences (e.g., if it's stateful or slow). But passing dummy arguments to a function for the sole purpose of seeing what it does is a strange practice.


Maybe either of the above is acceptable to you for your particular use cases. In general, for future readers, it is not a great idea to try to probe functions at runtime for their types.

A better general solution is to refactor so that you are comparing things which have been explicitly marked with distinguishable properties, like a discriminated union:

type ValidatorRetriever = { (): (x: unknown) => boolean; type: "ret" }
type ValidatorFunction = { (x: unknown): boolean; type: "fun" }

function isType(validator: ValidatorRetriever | ValidatorFunction, data: unknown): boolean {
    return validator.type === "ret" ? validator()(data) : validator(data)
}

function str() {
    return (x: unknown) => typeof x === "string"
}
str.type = "ret" as const;
console.log(isType(str, "abc")) // okay, true

function num(x: unknown) {
    return typeof x === "number"
}
num.type = "fun" as const;
console.log(isType(num, "abc")) // okay, false

function oopsieDoodle(...args: any[]) {
    return args.length === 0
}
oopsieDoodle.type = "fun" as const; // <-- if you make this "ret" you'll see errors
console.log(isType(oopsieDoodle, "abc")) // okay, false

Instead of relying on built-in function behavior, you're adding a type property to the functions in question and looking at that to discriminate between a ValidatorRetriever and a ValidatorFunction.

Again, this might be unnecessary for your particular use cases, and if length or test calling the function is acceptable then you can do that. But discriminated unions are well-supported in TypeScript in a way that runtime function inspection is not.

Playground link to code

Upvotes: 3

Related Questions