samhuk
samhuk

Reputation: 109

Type and sub-type dependent properties

A tricky TS challenge.

What It's About

Intersecting multiple discriminated union types together where there is a "type"-"sub-type" relationship.

The Question

Suppose one uses discriminated unions to have type-dependent properties (the allowed properties of an object depend on the value of one of the properties of the object). We will declare such a type, A:

enum AType {
  FOO,
  BAR
}

type ATypeToPropsMap = {
  [AType.FOO]: { foo: string }
  [AType.BAR]: { bar: string }
}

type AUnion = {
  [K in AType]: { type: K } & ATypeToPropsMap[K]
}[AType]

type A<TAType extends AType = AType> = AUnion & { type: TAType }

In this case, A can only have type = FOO and the property foo, or type = BAR and the property bar. One can use this A type like so:

const a1: AType = {
  type: AType.FOO,
  foo: "123" // <-- Allowed
}

const a2: AType = {
  type: AType.FOO,
  bar: "123" // <-- Not allowed, 
}

const a3: AType = {
  type: AType.BAR,
  bar: "123" // <-- Allowed, 
}

It's helpful to create a "helper" type that generalizes this discriminated union behavior:

type TypeDependantBase<
  TType extends string|number,
  TMap extends { [k in TType]: any },
  TTypePropertyName extends string,
> = {
  [K in TType]: { [k in TTypePropertyName]: K } & TMap[K]
}[TType] & { [k in TTypePropertyName]: TType }

This means that we can now succinctly define A like so:

type A<TAType extends AType = AType> = TypeDependantBase<TAType , {
  [AType.FOO]: { foo: string }
  [AType.BAR]: { bar: string }
}, "type">

We will create two new types, AFoo and ABar, that are also discriminated unions. Each of them are discriminated by their own new "sub-types", AFooSubType and ABarSubType respectively, each of which, as implied, pertain to each AType value:

enum AFooSubType {
  WIZZ,
  BANG
}

enum ABarSubType {
  CRASH,
  WOOSH
}

type AFoo<TAFooSubType extends AFooSubType = AFooSubType> = TypeDependantBase<TAFooSubType, {
  [AFooSubType.WIZZ]: { wizz: string }
  [AFooSubType.BANG]: { bang: string }
}, "subType">

type ABar<TABarSubType extends ABarSubType = ABarSubType> = TypeDependantBase<TABarSubType, {
  [ABarSubType.CRASH]: { crash: string }
  [ABarSubType.WOOSH]: { woosh: string }
}, "subType">

With A, AFoo, and ABar defined, one can then specify a type that maps AType to either AFoo or ABar

type ATypeToSubTypeMap = {
  [AType.FOO]: AFoo
  [AType.BAR]: ABar
}

And then, at last, one can create an intersection of A and AFoo/ABar like so:

type AWithSubType<TAType extends AType = AType> = A<TAType> & ATypeToSubTypeMap[TAType]

Now, the goal with this type, is to be able to define an object with the following type constraints:

const a: AWithSubType = {
  type: AType.FOO,
  foo: "123",      // <-- This is still forced to be "foo" just like before, because type = FOO
  subType: AFooSubType.WIZZ, // <-- This is forced to be AFooSubType because type = FOO
  wizz: "456" // <-- This is forced to be "wizz" and not "bang" because of the definition of AFoo
}

Unfortunately, and being the impetus for this question, is that this doesn't work! It doesn't force the subType property to be the correct type.

The ultimate question, is how does one do that? Thanks!

Update

As @jcalz showed, one can define all the type-sub-type mapping and props in one monolithic object to get the correct props enforcement:

type ADataMapping = {
  type: {
    [AType.FOO]: {
      foo: string,
      subType: {
        [AFooSubType.BANG]: { bang: string },
        [AFooSubType.WIZZ]: { wizz: string }
      }
    },
    [AType.BAR]: {
      bar: string,
      subType: {
        [ABarSubType.CRASH]: { crash: string },
        [ABarSubType.WOOSH]: { woosh: string }
      }
    }
  }
}

type ToDiscrimUnion<T, K extends keyof T> = T extends unknown ? {
  [P in keyof T[K]]: (
    Omit<T, K> & Record<K, P> & T[K][P]
  ) extends infer O ? { [Q in keyof O]: O[Q] } : never }[keyof T[K]
] : never

type AWithSubType = ToDiscrimUnion<ToDiscrimUnion<ADataMapping, "type">, "subType">

This answers the question, however it does come with the limitation of AWithSubType not having a generic which means one cannot explicitly specify the AType of an AWithSubType, i.e. AWithSubType<TAType>. This means one cannot do things like object spreading, for example:

const aTemplate = { type: AType.FOO, foo: "123" }

const a = { ...aTemplate, foo: "not 123" } // <-- Fails as `aTemplate` could be anything, so "foo" is not recognised.

However this is out of scope, of course.

Upvotes: 4

Views: 656

Answers (1)

jcalz
jcalz

Reputation: 329773

The main problem with your original type

type AWithSubType<TAType extends AType = AType> = A<TAType> & ATypeToSubTypeMap[TAType]

is that it does not distribute across unions in TAType. Presumably you want AWithSubType<AType.FOO | AType.BAR> to be equivalent to AWithSubType<AType.FOO> | AWithSubType<AType.BAR>. But it isn't. Intersecting A<AType> with ATypeToSubTypeMap[AType] is going to give you "cross-terms" in your union which allow things you don't want:

const bad: AWithSubType = {
  type: AType.FOO,
  foo: "123",
  subType: ABarSubType.CRASH,
  crash: "456"
} // no error

The easiest way to fix this is to explicitly turn it into a distributive conditional type where TAType is the checked type:

type AWithSubType<TAType extends AType = AType> =
  TAType extends unknown ? (
    A<TAType> & ATypeToSubTypeMap[TAType]
  ) : never

This might look like a no-op: after all, TAType always extends the unknown type. But it serves the purpose of splitting TAType apart into union members before evaluating the intersection, and then putting the results of those into a new union. And that fixes the problem:

const a: AWithSubType = {
  type: AType.FOO,
  foo: "123",
  subType: AFooSubType.WIZZ,
  wizz: "456"
} // okay

const bad: AWithSubType = {
  type: AType.FOO,
  foo: "123",
  subType: ABarSubType.CRASH,
  crash: "456"
} // error!

As mentioned though, this entire approach is complex and produces fairly hard to understand types. The type AWithSubType here evaluates to:

type AWithSubTypeEquivalent = (
  { type: AType.FOO; } & { foo: string; } & 
  { type: AType.FOO; } & AFoo<AFooSubType>
) | (
  { type: AType.BAR; } & { bar: string; } & 
  { type: AType.BAR; } & ABar<ABarSubType>
)

which is redundant (multiple copies of the type property in there) as well as confusing.

Personally, in this situation, I'd back way up and try to express everything as a single data structure which could then be programmatically transformed into the right sort of discriminated union. Something like this:

type ADataMapping = {
  type: {
    [AType.FOO]: {
      foo: string,
      subType: {
        [AFooSubType.BANG]: { bang: string },
        [AFooSubType.WIZZ]: { wizz: string }
      }
    },
    [AType.BAR]: {
      bar: string,
      subType: {
        [ABarSubType.CRASH]: { crash: string },
        [ABarSubType.WOOSH]: { woosh: string }
      }
    }
  }
}

And then we can automate the idea of "given an object type where one of the properties is itself a mapping from discriminant keys to value types, produce a discriminated union where this property name points to a single key, and the value type for that key is lifted up on level". Maybe that's weird, but here is how it could implemented:

type ToDiscrimUnion<T, K extends keyof T> = T extends unknown ? {
  [P in keyof T[K]]: (
    Omit<T, K> & Record<K, P> & T[K][P]
  ) extends infer O ? { [Q in keyof O]: O[Q] } : never }[keyof T[K]
] : never

And you can see it in action:

type FirstStep = ToDiscrimUnion<ADataMapping, "type">;

/*
type FirstStep = {
    type: AType.FOO;
    foo: string;
    subType: {
      [AFooSubType.BANG]: { bang: string; };
      [AFooSubType.WIZZ]: { wizz: string; };
    };
} | {
    type: AType.BAR;
    bar: string;
    subType: {
      [ABarSubType.CRASH]: { crash: string; };
      [ABarSubType.WOOSH]: { woosh: string; };
    };
}
*/

That makes a single discriminated union on the type discriminant, which can now be discriminated further on subType:

type AWithSubType = ToDiscrimUnion<FirstStep, "subType">

/* type AWithSubType = {
    type: AType.FOO;
    foo: string;
    subType: AFooSubType.WIZZ;
    wizz: string;
} | {
    type: AType.FOO;
    foo: string;
    subType: AFooSubType.BANG;
    bang: string;
} | {
    type: AType.BAR;
    bar: string;
    subType: ABarSubType.CRASH;
    crash: string;
} | {
    type: AType.BAR;
    bar: string;
    subType: ABarSubType.WOOSH;
    woosh: string;
} */

That's exactly the same type except it's fairly obvious from looking at it what the types mean. If you want to make it generic there are undoubtedly ways of doing so, such as just performing an Extract:

type AWithSubTypeGen<T extends AType> = 
  Extract<AWithSubType, { type: T }>;

type AFoo = AWithSubTypeGen<AType.FOO>;
/* 
type AFoo = {
  type: AType.FOO;
  foo: string;
  subType: AFooSubType.WIZZ;
  wizz: string;
} | {
  type: AType.FOO;
  foo: string;
  subType: AFooSubType.BANG;
  bang: string;
} 
*/

Most of this is drifting pretty far from the scope of the question as asked, though, so I should stop now.

Playground link to code

Upvotes: 2

Related Questions