Is it possible to conditionally narrow a return type based on a parameter property that's not passed in?
Solution 1:
Why conditional return types do not work
When you tried to return a conditional type, you simply hit a design limitation of TypeScript - unfortunately, it is not possible to return a conditional type that will be resolved depending on a type parameter. See this issue in the repo for the discussion.
Workaround
A possible solution could be passing the conditional return type explicitly as a type parameter to the find
method, but there is a catch. The problem (not sure if it was possible to do otherwise, though) is in how the ReadonlyArray
interface is defined.
It accepts a generic parameter for the type of values it contains, so when it comes down to defining the find
method, its type parameter S
must be a subtype of T
:
find<S extends T>(predicate: (this: void, value: T, index: number, obj: readonly T[]) => value is S, thisArg?: any): S | undefined;
Since your interfaces are wider than corresponding types of tuple members, union of them is not assignable to T
because number
(x
property in the interface) is not assignable to 5
(x
in the member type).
This can be worked around by taking advantage of declaration merging and adding an overload to the ReadonlyArray
interface like this:
interface ReadonlyArray<T> {
find<U>(predicate: (value: T, index: number, obj: readonly T[]) => unknown, thisArg?: any): { [ P in keyof this ] : this[P] extends U ? this[P] : never }[number] | undefined;
}
The technique relies on this
being inferred as a tuple which you can then filter for assignability to the resolved conditional type and extract the values that are left. Afterwards, you can tweak the howDoIDoThis
signature to pass the conditional type through to the find
method, and voila:
const howDoIDoThis = <T extends ObjName>(objName: T) => objs.find< T extends "1" ? HasX : NoX >((obj) => obj.name === objName)!;
howDoIDoThis("1"); //{ readonly name: "1"; readonly x: 5; readonly discriminator: true; }
howDoIDoThis("2"); //{ readonly name: "2"; readonly discriminator: false; }
Granted, this makes find
look a bit weird when you do not specify the type parameter because unknown
is now being inferred (this signature, being more lax on the type parameter, takes precedence over S extends T
one), but it does not lose in inference:
const normalTuple = [1,2,3] as const;
const neverMatches = normalTuple.find<string>((b) => b > 3); //undefined;
const hasMatch = normalTuple.find<number>((b) => b > 3); //1 | 2 | 3 | undefined;
The only caveat, as you can see from above is that now you can pass an arbitrary type unrelated to the type of values in the tuple, but then the return type will be filtered down to never | undefined
-> undefined
which will be caught by type guards.
Playground
Solution 2:
If the names are unique and objs
is a readonly array of readonly objects, you can statically determine the type by mapping over the array type. With a couple of utility types and a mapped type FilterByName
, you can define a type FindByName
like this:
type IndexKeys<A> = Exclude<keyof A, keyof []>
type ArrToObj<A> = {[K in IndexKeys<A>]: A[K]}
type Values<T> = T[keyof T]
type FilterByName<O extends Record<any, {name: string}>, N> = {
[K in keyof O]: O[K]['name'] extends N ? O[K] : never
}
type FindByName<N> = Values<FilterByName<ArrToObj<typeof objs>, N>>
The idea is that we use ArrToObj
to remove the array properties to get an object with string indices ('0'
, '1'
, ..) as keys and the array elements as values. With FilterByName
we then set the values that have the wrong name to never
, and use Values
to extract the filtered object type.
We can now type howDoIDoThis
by adding the signature obj is FindByName<N>
to the find callback.
const howDoIDoThis = <N extends ObjName>(objName: N) =>
objs.find((obj): obj is FindByName<N> => {return obj.name === objName})!
Applications of howDoIDoThis
on existing names will get the correct type:
const t1 = howDoIDoThis('1') // t1: { readonly name: "1"; readonly x: 5; readonly discriminator: true }
const t2 = howDoIDoThis('2') // t2: { readonly name: "2"; readonly discriminator: false }
const t3 = t1.x // t3: 5
const t4 = t2.x // type error: "Property 'x' does not exist on type .."
TypeScript playground