3

I'm interested in building a type predicate function like this, which will return true when the number is a real number and simultaneously make the type stricter:

function isRealNumber(input: number | undefined | null): input is number {
    return input !== undefined && input !== null && Number.isFinite(input);
}

However, this produces incorrect types in some circumstances when negated, e.g.:

const myNumber: number | null = NaN as any;
if (isRealNumber(myNumber)) {
    const b = myNumber;  // b is number, correct
} else {
    const b = myNumber;  // b is `null`, should be `null | number`
}

This can be worked around using multiple statements in the conditional, but this is less ideal:

function isRealNumber(input: number | undefined | null): boolean {
    return input !== undefined && input !== null && Number.isFinite(input);
}

const myNumber: number | null = NaN as any;
if (isRealNumber(myNumber) && myNumber !== null && myNumber !== undefined) {
    const b = myNumber;  // b is number, correct
} else {
    const b = myNumber;  // b is `null | number`, correct
}

Is there any way in typescript to have a single function which will correctly narrow the type without also producing incorrect types sometimes when negated?

1 Answer 1

3

You're looking for a "one-sided" or "fine-grained" type predicate as requested in microsoft/TypeScript#15048, but this is not currently directly supported in TypeScript. The issue is open, but it's not clear that anything will happen here.

There is a workaround mentioned in that issue, though. If you change your guarded type from input is number to input is RealNumber where RealNumber is some type assignable to but strictly narrower than number, then things will start working.

And while there is no actual distinction in the type system between RealNumber and number, you can "fake" one by using a technique called branded primitives. That's where you take a primitive type like number and intersect it with an object type containing a phantom "brand" or "tag" property. For example:

function isRealNumber(input: number | undefined | null): input is number & { __realNumber?: true } {
    return input !== undefined && input !== null && Number.isFinite(input);
}

const myNumber: number | null = NaN as any;
if (isRealNumber(myNumber)) {
    const b = myNumber;  // b is number & {__realNumber?: true}
} else {
    const b = myNumber;  // b is number | null
}

That looks more reasonable. If isRealNumber(myNumber) is true, then b is narrowed to number & {__realNumber?: true}. That {__realNumber?: true} property isn't going to actually be there at runtime (it's a "phantom" property), but that shouldn't matter much, since b is still a number. And when isRealNumber(myNumber) is false, then b is not narrowed at all.

Playground link to code

Sign up to request clarification or add additional context in comments.

4 Comments

Amazing, this was a huge help, thank you so much!
it seems that intersection type is only to combine object types. Therefore, the type number should be an object type, not a primitive type. Therefore, it must be converted to object before continuing. And thus the result is actually an interface, not number type. Am I correct?
No, that's not correct. You can use intersection on any type, like (number | "abc") & (123 | string) and get the intersection of the types (which in this case is "abc" | 123). Branding primitives is a trick that TS allows; technically string & {a: number} should be never since there are no primitive strings with an a property (and no, that doesn't make it String, which is a widening of string, and intersections narrow, not widen). But TS allows it to be a type because there are cases where it is useful to "tag" primitive types to distinguish them in the type system.
(see prev comment.) It's a type-system-only fiction that does not have anything to do with runtime. It's a workaround for the lack of nominal types in TypeScript. Maybe this isn't clear enough for you to understand... if so, you might want to ask another question about branded primitives in TypeScript and what they mean (after you search for an existing one), because the comment section of Stack Overflow isn't really a good place for that. Good luck!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.