Reputation: 67554
I think my code will show what I'm trying to ask better than the title:
export const objs = [
{
name: '1',
x: 5,
discriminator: true
},
{
name: '2',
discriminator: false
},
] as const;
export type ObjName = typeof objs[number]['name'];
export type Obj = HasX | NoX;
export interface HasX {
name: ObjName;
x: number;
discriminator: true;
}
export interface NoX {
name: ObjName;
discriminator: false;
}
const typeCheckObjs = (obj: readonly Obj[]): void => {};
typeCheckObjs(objs);
// I want this return type to be conditional instead of a union. Conditional on the objName's discriminator value
const howDoIDoThis = (objName: ObjName): HasX | NoX => {
return objs.find((obj) => {return obj.name === objName})!
}
You can experiment with this code in the Playground
I'm wondering if it's possible to conditionally return the type in the last function? To do so, I'd need to associate objName
back to the element in the list and figure out what type it is, then conditionally return that type.
I figure this can't be done for a few reasons:
If what I'm asking for isn't possible, I'm okay with tweaking the code in some ways. But I still want to be able to have the compiler check objName: ObjName
for correctness.
Here's what I've tried:
const howDoIDoThis = <T extends Obj,>(objName: T["name"]): T extends HasX ? HasX : NoX => {
const found = objs.find((obj) => {return obj.name === objName})!
return found.discriminator ? found as HasX : found as NoX;
}
According to the compiler, the type signature is fine, but the last line doesn't compile.
I'm not completely convinced this type signature is saying what I want it to, anyway.
Here's another attempt with the same error message:
const howDoIDoThis = <T extends Obj,>(objName: T["name"]): T["discriminator"] extends true ? HasX : NoX => {
const found: Obj = objs.find((obj) => {return obj.name === objName})!
return found.discriminator === true ? found as HasX : found as NoX;
}
This compiles, but the error suggests it provides no useful benefit:
const howDoIDoThis = <T extends Obj,>(objName: T["name"]): T["discriminator"] extends true ? HasX : NoX => {
const found: Obj = objs.find((obj) => {return obj.name === objName})!
return (found.discriminator ? found as HasX : found as NoX) as T["discriminator"] extends true ? HasX : NoX
}
Upvotes: 1
Views: 974
Reputation: 15136
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 .."
Upvotes: 1
Reputation: 10375
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.
Upvotes: 4