Jason Siefken
Jason Siefken

Reputation: 789

Typescript type guard that only partially narrows

I am trying to make a type guard that narrows the string type but doesn't narrow it all the way to an explicit list of strings.

Here is an example of the type of code I am writing:

type Node = { type: string, content: string };
type Macro extends Node { type: "macro" };

function generateGuard(filter: Set<string>) {
    function strGuard(s: any): s is Macro & {content: unknown} {
        return typeof s === "object" &&
                  s.type === "macro" && 
                  filter.has(s.content);
    }
    return strGuard;
}
const isMacro = (s: any) => typeof s === "object" &&
                            s.type === "macro"
const specialMacro = generateGuard(new Set(["x"]));

let xxx: Node = {type: "macro", content: "some string" };

function task1(){
    if (isMacro(xxx)) {
        if (specialMacro(xxx)) {
            // Do something for special `Macro`
            xxx;
            // ?^ Macro
        } else {
            // Do something for non-special `Macro`
            xxx;
            // ?^ never
        }
    } else {
        // code for non-macro `Node`
    }
}

function task2(){
    if (specialMacro(xxx)) {
        // Do something for special `Macro`
        xxx;
        // ^? Macro
    }
}

The issue is that xxx has type never in the else block. I want it to have some sort of T extends Macro type instead (or a the full Macro type would be fine, it just cannot be never). The & {content: unknown} doesn't narrow the type because {content: string} is already more specific.

Is there some way to type generateGuard such that it narrows {content: string} by some "unknown" amount?

Upvotes: 1

Views: 658

Answers (2)

Sean Vieira
Sean Vieira

Reputation: 159955

The issue is that you're hiding several interesting bits from TypeScript. By specifying that the set is a set of distinct string types you can get the narrowing you want without introducing a too-specific constraint in content in the false case:

type AstNode = { type: string, content: string };
type Macro = AstNode & { type: "macro" };

function generateGuard<C extends string>(filter: Set<C>) {
    function strGuard(s: any): s is Macro & {content: C} {
        return typeof s === "object" &&
                  s.type === "macro" && 
                  filter.has(s.content);
    }
    return strGuard;
}

Usage now works:

const specialMacro = generateGuard(new Set(["x", "y", "z"]));

let oneNode: AstNode = {type: "macro", content: "some string" };
if (specialMacro(oneNode)) {
    // `oneNode` is type Macro
    oneNode.content // and content is of type "x" | "y" | "z";
} else {
    // Type AstNode
    oneNode.content;  // and content is of type `string`
}

See it in action on the playground

Upvotes: 2

Dimava
Dimava

Reputation: 10879

Playground Link

function hasPrefix<P extends string>(str: string, prefix: P): str is `${P}${string}` {
    return typeof str === 'string' && str.startsWith(prefix);
}
declare const Brand: unique symbol;
type Id = string & {[Brand]?: 'id'}
function isId(str: string): str is Id {
    return !!str.match(/^\w+$/);
}

let xxxx = "some string" as const;
if (hasPrefix(xxxx, 'some')) {
    xxxx;
    // ^?
} else {
    xxxx;
    // ^?
}
if (hasPrefix(xxxx, 'soon')) {
    xxxx;
    // ^?
} else {
    xxxx;
    // ^?
}
let xxxy = "some string";
if (hasPrefix(xxxy, 'wasd')) {
    xxxy;
    // ^?
} else {
    xxxy;
    // ^?
}
if (isId(xxxy)) {
    xxxy;
    // ^?
} else {
    xxxy;
    // ^?
}

Upvotes: 0

Related Questions