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?
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 '';
}
}
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}`;
}
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.