I want to create a Typescript utility type ExtractOnProp
that extracts an object from a union type T
whose specific property Prop
has a requested type ValueType
.
type ExtractOnProp<T, Prop extends keyof T, ValueType> = /* ... */;
type Test1 = ExtractOnProp<
| { value: "test1" | "test2", prop1: any }
| { value: "test1", prop2: any }
| { value: "test3", prop3: any },
"value",
"test1">;
// Test1 == { value: "test1", prop1: any } | { value: "test1", prop2: any };
// Real use case is more complicated but example usage:
// We want to find an element from an array of possible values
function narrow<
T extends { value: string },
Value extends readonly T['value'][],
>(
elements: readonly T[],
filter: Value,
): ExtractOnProp<T, 'value', Value[number]> {
const result = elements.find((el) => filter.includes(el.value as any));
if (!result) {
throw new Error('not found');
}
return result as any;
}
narrow(
[
{ value: 'test1', prop1: 1 },
{ value: 'test2', prop1: 2 },
{ value: 'test3', prop3: 3 },
] as const,
['test1', 'test3'] as const,
);
// { value: 'test1', prop1: 1 } | { value: 'test3', prop3: 3 }
Here are the things I've tried
type ExtractOnProp<T, Prop extends keyof T, ValueType> = T[Prop] extends ValueType ? T : never;
// Always returns never
// The closest I got was property type intersection
type IntersectPropType<T, Prop extends keyof T, ValueType> = T[Prop] extends infer S
? S extends ValueProp
? S
: never
: never;
type Test1 = IntersectPropType<{ value: 'test1' | 'test2' } | { value: "test3"; prop: any }, 'value', 'test2' | 'omitted'>;
// Test1 = 'test2';
Edit:
I've gotten closer now by narrowing the union, but not the property type itself.
type ExtractOnProp<T, Prop extends keyof T, ValueType> = T extends {
[_ in Prop]: infer S;
}
? ValueType extends S
? T
: never
: never;
type Test = ExtractOnProp<
{ value: 'a' | 'c'; prop1: any } | { value: 'b'; prop2: any },
'value',
'a'
>;
// Test = { value: 'a' | 'c', prop1: any }
CodePudding user response:
Given your requirements as stated, one approach might be:
type ExtractOnProp<T, K extends keyof T, V> =
T extends unknown ? V extends T[K] ?
{ [P in keyof T]: P extends K ? T[P] & V : T[P] }
: never : never
This is a distributive conditional type in both T
and V
. That means the operation will distribute across union types in T
and V
; T
and V
will be split into their union members, the type will be evaluated for each such member, and then the result will be rejoined into a union. So ExtractOnProp<T1 | T2, K, V1 | V2>
will be the same as ExtractOnProp<T1, K, V1> | ExtractOnProp<T2, K, V1> | ExtractOnProp<T1, K, V2> | ExtractOnProp<T2, K, V2>
. You definitely want to do this with T
, since part of the point is to examine each union member of T
separately; and if you don't distribute across unions in T
, you end up examining all of T
at once which is not going to help.
Conditional types are only distributive this way when the checked type is a generic type parameter. The type V extends T[K] ? { [P in keyof T]: P extends K ? T[P] & V : T[P] } : never
would not be distributive in T
. In order to make it so, we can wrap it in the seemingly useless T extends unknown ? (...) : never
. Of course T
will extend unknown
; we don't really want to check it. Instead we just want to split T
into its union members before evaluating the rest of the type.
So, the rest of the type is
V extends T[K] ? { [P in keyof T]: P extends K ? T[P] & V : T[P] } : never
This has the effect of splitting V
into its union members also. If the member of V
is not assignable to the property at key K
of T
, then we want to drop this T
member from the output (hence the never
). If it is assignable, then we want to grab this member. But instead of just returning T
, we map it to {[P in keyof T]: P extends K ? T[P] & V : T[P] }
. That type is basically just T
except for the property at K
, which gets intersected with V
. This way if the property is wider than V
it will be narrowed.
Let's test it out:
type Test = ExtractOnProp<
{ value: "test1" | "test2", prop1: any } |
{ value: "test1", prop2: any } |
{ value: "test3", prop3: any },
"value", "test1">
// type Test =
// { value: "test1"; prop1: any; } |
// { value: "test1"; prop2: any; }
Looks good, the "test3"
member is dropped entirely, while the "test1" | "test2"
member is narrowed a bit.
Next test:
const ret = narrow([
{ value: 'test1', prop1: 1 },
{ value: 'test2', prop1: 2 },
{ value: 'test3', prop3: 3 },
] as const, ['test1', 'test3'] as const);
/* const ret: {
readonly value: "test1";
readonly prop1: 1;
readonly prop3?: undefined;
} | {
readonly value: "test3";
readonly prop3: 3;
readonly prop1?: undefined;
} */
Again, seems reasonable. More:
type Test1 = ExtractOnProp<
{ value: 'test1' | 'test2' } |
{ value: "test3"; prop: any },
'value', 'test2' | 'omitted'>;
// type Test1 = { value: "test2"; }
Fine, I think? Last one:
type Test2 = ExtractOnProp<
{ value: 'a' | 'c'; prop1: any } |
{ value: 'b'; prop2: any },
'value',
'a'
>;
// type Test2 = { value: "a"; prop1: any;}
Okay, those all do what I think you want. These sorts of type manipulations always seem to have a lot of edge cases, so I'd be surprised if there isn't some feature that behaves differently from how you want. So test thoroughly and hopefully if something isn't the way you want it you can modify this yourself.