Reputation: 1714
Why is the following object extraction flagged as incorrect by the compiler? playground link
type SplitTextProps<T, N extends keyof T> = T[N] extends string
? {
object: T
getter: N
}
: never
const splitText = <T, N extends keyof T>({
object,
getter
}: SplitTextProps<T, N>) => {
const value = object[getter]
return value.split(' ')
}
interface Datatype {
value: string
id: number
}
const record: Datatype = { value: 'Green is good', id: 5 }
const result = splitText({ object: record, getter: 'value' })
console.log(result)
value.split(' ')
is marked as incorrect despite the type definition on SplitTextProps
. The exact error message is
Property 'split' does not exist on type 'T[N]'
Edit (Adding more info): In fact, the restriction actually works when trying to pass incorrect props, the compiler throws the following error when trying to use splitText({object: record, getter : 'id'})
Argument of type '{ object: Datatype; getter: string }' is not assignable to parameter type 'never'.
āļø because id
prop of interface Datatype
isn't a string
. Only the happy path fails within splitText
func š.
Upvotes: 3
Views: 113
Reputation: 328282
The problem here is that the TypeScript compiler cannot generally follow "higher order" logical operations with as-yet-unspecified generic types. Inside the body of splitText()
, the type SplitTextProps<T, N>
is a conditional type that depends on generic type parameters T
and N
. When the compiler tries evaluates the type of object
and getter
, it takes the shortcut of widening the conditional type to the union of its true and false branches, so it becomes { object, getter } : { object: T, getter: N }
. Then value
is of type T[N]
, which is correct. But notably, any information about T[N] extends string
has unfortunately been lost. So value.split()
is an error.
On the other hand, callers of splitText()
usually don't have this problem. When you call splitText({ object: record, getter: 'value' })
, the compiler can infer that you are specifying T
as DataType
and N
as "value"
. And therefore that the input is of type SplitTextProps<DataType, "value">
which can be evaluated directly to be { object: DataType, getter: "value" }
, and it type checks. If you call it some with some unexpected input, you'll likely get SplitTextProps<SomeT, SomeN>
evaluating to never
, and you get the expected error.
In short: unresolved generic conditional types are hard to deal with, so callers using specific types tend to be happy, and implementers using generic types can sometimes be unhappy.
If you want to leave things as-is, and you're sure you've implemented things properly, you could just assert to the compiler that value
is a string
:
const splitText = <T, N extends keyof T>(
{ object, getter }: SplitTextProps<T, N>
) => {
const value = object[getter] as string; // <-- assert
return value.split(' '); // okay
}
Otherwise, you'd need to refactor to a form the compiler can evaluate more correctly. Personally, I would stay away from conditional types unless they are necessary to represent your operation. For the code example as given, I'd be inclined to write it this way:
type SplitTextProps<N extends PropertyKey> = {
object: Record<N, string>, getter: N
}
const splitText = <N extends PropertyKey>(
{ object, getter }: SplitTextProps<N>
) => {
const value = object[getter];
return value.split(' '); // okay
}
Here, SplitTextProps
only cares directly about the literal type of getter
N
, while your T
has been replaced with Record<N, string>
, using the Record<K, V>
utility type. The compiler is able to understand that Record<K, V>[K]
is assignable to V
, even for generic K
. So then value
is seen of a type assignable to string
, and you are allowed to call split()
on it.
Of course whether or not you can refactor your conditional type to something else that the compiler understands depends strongly on the use case, so there are probably times when a type assertion is your easiest and most straightforward solution.
Upvotes: 2