DragonBobZ
DragonBobZ

Reputation: 2444

Evaluation of Nested Conditional Type - Resulting Types Not Behaving as Expected

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions