Reputation: 2444
I have a type which represents a payload from an API call. If a foreign-key is defined on the payload, the payload is known to also contain metadata corresponding to the related record:
type Payload = {
id: number
districtAdmin?: string | null
// added after request for more info
dedicatedSupport?: string | null
edges: {
// if typeof districtAdmin === "string"
districtAdmin: {
name: string
}
// else
districtAdmin: never
// same with dedicatedSupport
}
}
This Playground is my attempt to implement this type (which only attempts to handle one relationship right now):
type UserDataPayload = {
id: string
username: string
}
type OrganizationPayload<
TR extends string | null = string | null,
> = {
id: number
districtAdmin?: TR
edges: {
districtAdmin?: TR extends string ? UserDataPayload : never
}
}
const daUser: UserDataPayload = {
id: "da",
username: "da.bob"
}
const daOrg: OrganizationPayload = {
id: 1,
districtAdmin: "da",
edges: {
districtAdmin: daUser
}
}
// (property) districtAdmin?: UserDataPayload | undefined
// Object is possibly 'undefined'.(2532)
console.log(daOrg.edges.districtAdmin.username)
Upvotes: 1
Views: 223
Reputation: 327994
The conditional type you've got here,
type OrganizationPayload<TR extends string | null = string | null> = {
id: number
districtAdmin?: TR
edges: {
districtAdmin?: TR extends string ? UserDataPayload : never
}
}
is generic, and therefore depends on a type parameter TR
in order to specify whether the districtAdmin
property is either present in both the top level object and the edges
subproperty (by specifying string
as TR
), or absent in both places (by specifying null
as TR
).
Well, that seems to be the intent, anyway. That property is optional no matter what, even if you specify string
as TR
:
type OPS = OrganizationPayload<string>;
/* type OPS = {
id: number;
districtAdmin?: string | undefined;
edges: {
districtAdmin?: UserDataPayload | undefined;
};
} */
So maybe the type should actually be
type OrganizationPayload<TR extends string | null = string | null> = (
TR extends string ? { districtAdmin: string } : { districtAdmin?: null }
) & {
id: number;
edges: (
TR extends string ? { districtAdmin: UserDataPayload } :
{ districtAdmin?: never }
)
};
Which now results in
type OPS = OrganizationPayload<string>;
/* type OPS = {
districtAdmin: string;
} & {
id: number;
edges: {
districtAdmin: UserDataPayload;
};
} */
type OPN = OrganizationPayload<null>;
/* type OPN = {
districtAdmin?: null | undefined;
} & {
id: number;
edges: {
districtAdmin?: undefined;
};
} */
which is equivalent to what you're trying to express, I think.
But then you annotate a variable as being just type OrganizationalPayload
with no type argument:
const daOrg: OrganizationPayload = { ... }
Perhaps your intent there is that the compiler should look at the initializing value and infer the most appropriate argument for TR
. That's not what happens.
Your definition of OrganizationalPayload
gives TR
a default of the union type string | null
. When you leave out the type argument, the compiler just uses that default:
// const daOrg: OrganizationPayload<string | null>
And that type evaluates to
type OP = OrganizationPayload;
/* type OP = ({
districtAdmin: string;
} | {
districtAdmin?: null | undefined;
}) & {
id: number;
edges: {
districtAdmin: UserDataPayload;
} | {
districtAdmin?: undefined;
};
} */
which is a type where both properties are optional no matter what, and the presence of districtAdmin
at the top level doesn't imply that it will be present in edges
. Oops.
And when you assign that initializing value to the variable, the compiler does not keep track of the particular value that was inside the edges
property. The part of OrganizationPayload
where edges
is specified is not itself a union type, so there is no narrowing upon assignment. So you get the error:
console.log(daOrg.edges.districtAdmin.username) // error!
// -------> ~~~~~~~~~~~~~~~~~~~~~~~~~
// Object is possibly 'undefined'.
Again, there is no direct way to ask the compiler to infer TR
from the initializing value. There's a suggestion at microsoft/TypeScript#32794 to support this, and if this is ever implemented it might look like
const daOrg: OrganizationPayload<infer> = { ... }
But for now it's not part of the language. Often in cases like this people write generic helper functions to infer the type parameter, like
const asOrgPayload = <TR,>(op: OrganizationalPayload<TR>) => op;
const daOrg = asOrgPayload({ ... });
but you're not interested in this sort of approach. And even if you were it would probably need to be modified to get inference to work, since inferring from a conditional type is tricky.
Perhaps OrganizationalPayload<string | null>
evaluates to the wrong type, since it allows "cross-correlated" terms. Since OrganizationalPayload<string>
is fine, and OrganizationalPayload<null>
is fine, maybe you want OrganizationalPayload<string | null>
to evaluate to the union of them.
If so, you could again modify OrganizationalPayload
to be a distributive conditional type:
type OrganizationPayload<TR extends string | null = string | null> =
TR extends unknown ? ((
TR extends string ? { districtAdmin: string } : { districtAdmin?: null }
) & {
id: number;
edges: (
TR extends string ? { districtAdmin: UserDataPayload } :
{ districtAdmin?: never }
)
}) : never;
And now OrganizationalPayload
evaluates to the proper (albeit ugly) type:
type OP = OrganizationPayload;
/* type OP = (
{ districtAdmin: string; } &
{ id: number; edges: { districtAdmin: UserDataPayload; }; }
) | (
{ districtAdmin?: null | undefined; } &
{ id: number; edges: { districtAdmin?: undefined; }; }
) */
And narrowing upon assignment happens correctly now, and everything behaves wonderfully!
const daOrg: OrganizationPayload = {
id: 1,
districtAdmin: "da",
edges: { districtAdmin: daUser }
}
console.log(daOrg.edges.districtAdmin.username) // okay
Of course that OrganizationPayload
definition is now fairly ugly with three conditional types in it. They can be rearranged to have just a single conditional type that is equivalent:
type OrganizationPayload<TR extends string | null = string | null> =
{ id: number } & (
TR extends string ?
{ districtAdmin: string; edges: { districtAdmin: UserDataPayload } } :
{ districtAdmin?: null; edges: { districtAdmin?: never } }
);
Or possibly even just:
type OrganizationPayload<TR extends string | null = string | null> =
TR extends string ?
{ id: number; districtAdmin: string; edges: { districtAdmin: UserDataPayload } } :
{ id: number; districtAdmin?: null; edges: { districtAdmin?: never } };
Which becomes, if you don't specify TR
:
type OP = OrganizationPayload;
/* type OP =
{ id: number; districtAdmin: string; edges: { districtAdmin: UserDataPayload; }; } |
{ id: number; districtAdmin?: null; edges: { districtAdmin?: never; };
*/
A plain union type.
That implies that conditional types, while possibly useful to the typings developer so they don't have to repeat things, really aren't what's useful to the users of the type... unions are the supported method to do the kind of narrowing you're expecting.
But you're not interested in that sort of approach either. Unions are a no-go for you, presumably because your type will end up with some large power of two of union members if you write that (like this).
So at this point I'd say I'm stuck. Conditional types don't, in and of themselves, give you the behavior you're looking for. You'd need to either use a generic helper function or a union for inference and/or assignment narrowing. Or some other possible approach (I didn't get into existentially quantified generic types here because they're not directly part of the language and even if they were you wouldn't get the narrowing you're apparently looking for.)
Hopefully at least you understand why the current approach doesn't work.
Upvotes: 1