Search code examples
typescriptgenericstypescript-genericstypeguards

How to assign these type predicates' types to their parameters' types?


The followings are what I wanna achieve:

function isString<T>( value: T ): value is T extends string ? T : string {

  return typeof value === "string";

}
function isNotString<T>( value: T ): T extends string ? unknown : T {

  return typeof value !== "string";

}

But I receive an error that says, "A type predicate's type must be assignable to its parameter's type." which is expected.

I don't wanna achieve the followings btw:

function isString<T>( value: T ): Extract<T, string> {

  return typeof value === "string";

}
function isNotString<T>( value: T ): Exclude<T, string> {

  return typeof value !== "string";

}

Because, both of these may infer the never type that I don't want. That says, I always need to have a known type to deal with.

Can you please help me achieve this TypeScript way? :-)


Reply to the answers

Thank you so much for your insightful answers, guys. Here're my replies:

@Linda Paiste

Don't bother creating an isNotString type guard because it won't do what you want. Instead, check that isString is false and typescript will infer everything properly.

Do you see anything wrong with the following function? If I'm not wrong, I think it achieves what I expect to.

function isNotString<T>( value: T ): value is Exclude<T, string> {
    return typeof value === "string";
}

I personally like to include a generic and assert that value is T & string rather than just value is string so that no information is lost if the type is already known to be a specific subset of string.

I like this approach, but I achieved this previously in a weird way. I'm not going to share this here, as it was an insane approach.

We didn't have any never in our return statement, but we still get never in our code because it's the only logical type if our a: string variable is not string.

Can't agree more. I was surely out of my mind yesterday. :-)

It's worth noting that typescript's built-in type guards don't actually refine the type in the negative case. Which just goes to show that you can't really do it properly when dealing with an unknown type like T.

I do agree dear. I just wanna have it for verbose purpose. Maybe I'm wrong, but isNotString seems more verbose to me than ! isString.

@Andrei Tătar

Can you please take a look at this Playground?

I can see Exclude is working as expected, but value: T | string is not. Feel free to point out if I missed something.


Solution

  • Short Answer

    Don't bother creating an isNotString type guard because it won't do what you want. Instead, check that isString is false and typescript will infer everything properly.

    Long Answer

    @Andrei's answer is correct that a type guard must assert its return with value is. I want to address the issue of never. A type guard is a true/false check. One of those branches checks out and the other is illogical so it will be never.

    Let's say that this is our type guard. I personally like to include a generic and assert that value is T & string rather than just value is string so that no information is lost if the type is already known to be a specific subset of string.

    function isString<T>( value: T ) : value is T & string {
      return typeof value === "string";
    }
    

    Now we want to use this type guard in an if statement. Let's see what happens when we do that with a variable whose type is already known.

    const a: string = "something";
    
    if (isString(a)) {
        console.log(a); // `a` here is `string`
    } else {
        console.log(a); // `a` here is `never` 
    }
    

    We didn't have any never in our return statement, but we still get never in our code because it's the only logical type if our a: string variable is not string.

    Defining an isNotString typeguard isn't really necessary because you could just as well check ! isString(). But if we want to do it, it is going to return never when called with a string. Anything else wouldn't make sense. The assertion is applied only when the function is true. So if a string is not a string, then what is it?

    Honestly it is really hard to define a type guard that asserts a negative case, ie. value is not string, because type guards are designed to assert a positive. Exclude seems like it should work, but it's not able to discriminate unions because the union doesn't extend string so it always returns never. I can see why this is undesirable behavior that you don't want. So just don't do this. You already have a guard that sees if something is a string, so use that to see if it's not a string and it will do what you want.

    function checkUnion(value: string | number) {
        if (!isString(value)) {
            console.log(value); // `value` is `number`
        }
        if (isString(value)) {
            console.log(value) // `value` is `string`
        }
    }
    

    It's worth noting that typescript's built-in type guards don't actually refine the type in the negative case. Which just goes to show that you can't really do it properly when dealing with an unknown type like T.

    function checkGeneric<T>(value: T) {
        if ( typeof value !== "string" ) {
            console.log(value); // value is still just T
        }
        if ( typeof value === "string" ) {
            console.log(value); // value is T & string
        }
    }
    

    Playground Link