简体   繁体   中英

Typescript intellisense cannot infer generic type of conditional types based on condition

I've setup an interface that accepts a generic that extends a union. On that generic, I created a set of conditional types.

type SessionContentType = "a" | "b" | "c";

interface SessionDetails<T extends SessionContentType> {
    id: string;
    sectionId?: string;
    segments?: string[];
    content: {
        type: T;
        contentId?: T extends Exclude<SessionContentType, 'b'> ? string : never;
        channelId?: T extends Exclude<SessionContentType, 'a'> ? string : never;
        suffix?: T extends Exclude<SessionContentType, 'a' | 'c'> ? string : never;
    }
}

Up to here, it seems everything fine. SessionDetails["content"]["suffix"] has the following type:

在此处输入图片说明

So, generic must be of type 'b' for suffix to not be never .

Then I create a function that uses a structure that complies to SessionDetails with a known T .

function getSuffixFromSession(
    session: SessionDetails<Exclude<SessionContentType, 'a'>>
) {
    if (session.content.type === 'c') {
        session.content.type; // This is correctly of type 'c'
        session.content.suffix; // I expect this to be of type 'never' but it is `string | undefined`

        return '';
    }

    const suffix = session.content.suffix ?? '';
    return suffix && `-${suffix}`;
}

Just to confirm I was doing everything correctly, I added those two detailed statements to see their types.

session.content.type is, as expected, of type 'c' . But if type is of type c (and therefore this should be writable as SessionDetails<'c'> ), why does session.content.suffix resolves as type string | undefined string | undefined instead of never (or never | undefined , actually?)?

If I do

function getSuffixFromSession(
    session: SessionDetails<'c'>
) {
    if (session.content.type === 'c') {
        session.content.type; // This is correctly of type 'c'
        session.content.suffix; // I expect this to be of type 'never' but it is `undefined`

        return '';
    }

    const suffix = session.content.suffix ?? '';
    return suffix && `-${suffix}`;
}

session.content.suffix is undefined instead of never , but still this is not what I'd like to obtain.

Is there something wrong that am I doing or that probably I'm assuming?

Here's the playground url.

Thank you.

Several things are going on here.


One is that your SessionDetails<T> type does not actually constrain things the way you think it does. When T is a union like "b" | "c" "b" | "c" , the type of SessionDetails<T>["content"] is a single object type and not a union:

type Content = SessionDetails<Exclude<SessionContentType, 'a'>>["content"];
/* type Content = {
  type: "b" | "c";
  contentId?: string;
  channelId?: string;
  suffix?: string;
} */

Which means this is acceptable to the compiler:

const sd: SessionDetails<"b" | "c"> = {
    id: "",
    content: {
        type: "c",
        suffix: "oopsie" // <-- definitely a string
    }
}; // no error
getSuffixFromSession(sd); 

Since your conditional type for content.suffix evaluates to string when T is "b" | "c" "b" | "c" , then even if you verify that session.content.type is "c" inside the implementation of getSuffixFromSession() , the type of suffix does not depend on it, and control flow analysis does nothing.


The issue having to do with undefined versus never is that when you read an optional property that happens to be missing, you will get a value of undefined :

interface Foo { bar?: never };
const foo: Foo = {}
console.log(foo.bar) // undefined

The never type represents an impossible condition; there are no values of type never , and if you get to a place in your code where the compiler thinks a value is of type never , then either the compiler is mistaken, or that place will never be reached at runtime.

When you have an optional property declared to be of type never , you are essentially saying that you expect the property not to exist at all. There are some nuances around assigning undefined to such a property; it's currently allowed with --strictNullChecks but TS4.4 will introduce an --exactOptionalPropertyTypes compiler flag that will prevent it. But either way, when you read an optional property of type never , you are definitely going to get an undefined .

So, receiving undefined is desired behavior.


Anyway, the only way to get this kind of "check one property of an object to narrow the type of the whole object" functionality in TypeScript is to use a discriminated union . If you want your content property to be a union and not a single object type, you need to distribute that property type across any union in T . A distributive conditional type will do this automatically:

interface SessionDetails<T extends SessionContentType> {
    id: string;
    sectionId?: string;
    segments?: string[];
    content: T extends any ? { // <-- distributes
        type: T;
        contentId?: T extends Exclude<SessionContentType, 'b'> ? string : never;
        channelId?: T extends Exclude<SessionContentType, 'a'> ? string : never;
        suffix?: T extends Exclude<SessionContentType, 'a' | 'c'> ? string : never;
    } : never
}

Which you can verify:

type Content = SessionDetails<Exclude<SessionContentType, 'a'>>["content"];
/* type Content = {
  type: "b";
  contentId?: undefined;
  channelId?: string | undefined;
  suffix?: string | undefined;
} | {
  type: "c";
  contentId?: string | undefined;
  channelId?: string | undefined;
  suffix?: undefined;
} */

And thus the following assignment fails:

const sd: SessionDetails<"b" | "c"> = {
    id: "",
    content: { // error!
  //~~~~~~~ <--  Types of property 'suffix' are incompatible.
        type: "c",
        suffix: "oopsie"
    }
}

And control flow analysis inside the implementation of getSuffixFromSession() proceeds as desired:

function getSuffixFromSession(
    session: SessionDetails<Exclude<SessionContentType, 'a'>>
) {
    if (session.content.type === 'c') {
        session.content.type;
        session.content.suffix; // undefined
        return '';
    }
}

Playground link to code

You're almost correct, you just want to swap string for T .

Like this:

type SessionContentType = "a" | "b" | "c";

interface SessionDetails<T extends SessionContentType> {
    id: string;
    sectionId?: string;
    segments?: string[];
    content: {
        type: T;
        contentId?: T extends Exclude<SessionContentType, 'b'> ? T : never;
        channelId?: T extends Exclude<SessionContentType, 'a'> ? T : never;
        suffix?: T extends Exclude<SessionContentType, 'a' | 'c'> ? T : never;
    }
}


function getSuffixFromSession(
    session: SessionDetails<Exclude<SessionContentType, 'a'>>
) {
    if (session.content.type === 'c') {
        session.content.type; // => 'c'
        session.content.suffix; // => 'b'

        return '';
    }

    const suffix = session.content.suffix ?? '';
    return suffix && `-${suffix}`;
}

TS Playground

Since never would not display directly, you can replace the never with number , and you can see the following类型接口

I haven't dived into the TypeScript compiler source code, but I'm guessing that typescript is inferring T as 'b' | 'c' 'b' | 'c' here, so the session is inferred to SessionDetails<'b' | 'c'> SessionDetails<'b' | 'c'> , eventually the session.content.suffix will be inferred to string | never | undefined string | never | undefined

If you really want impl like this, maybe this is a better way, explicitly tell the TypeScript session is a SessionDetail<'c'>

(session as SessionDetails<'c'>).content.suffix; // (property) suffix?: never | undefined

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM