TypeScript
TypeScript copied to clipboard
Allow conditional check on generic parameters
Suggestion
🔍 Search Terms
List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily and help provide feedback.
- generics
- constraints
- conditional
- check
✅ Viability Checklist
My suggestion meets these guidelines:
- [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
- [x] This wouldn't change the runtime behavior of existing JavaScript code
- [x] This could be implemented without emitting different JS based on the types of the expressions
- [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- [x] This feature would agree with the rest of TypeScript's Design Goals.
⭐ Suggestion
Allow conditional constraints on generic parameters.
📃 Motivating Example
- You can constrain generics by type calculation, like this:
type EQ<A, B> = A extends B ? B extends A ? true : false : false
function f<A, B>(a:A, b:B) where [EQ<A,B>] { ... }
f(1,2) // ok
f(1,'a') // err
This describes a function whose two arguments must be of the same type.
The where
field provides an list, each item of this list is a type calculation, and the function can only be called if each item is true
.
- For simplicity, this constraint is only useful when calling the function, and does not affect the judgment of this generic type inside the function.
type IsString<A> = A extends String ? true : false
function f<A>(a:A) where [IsString<A>] {
// Inside the function, don't know that the type of A is a string.
}
f('a') // ok
f(1) // err
- This check is post-processing, that is, when calling the function, the type of the generic type is first deduced through parameters, etc., and finally it is checked whether the constraints in
where
are satisfied. So this won't affect the existing generic parameter logic.
💻 Use Cases
I think this provides two benefits:
- Can describe generic conditions more freely, not just subtype constraints.
For example, there is a function that expects to enter a phone number:
type IsPhoneNumber<A extends string> = ...
function f<A extends string>(a:A) where [IsPhoneNumber<A>] { ... }
- Allows describing the relationship between multiple generics, and is more expressive.
For example, a function takes two arguments, both of which are items in a given list, and the arguments cannot be duplicates:
type EQ<A, B> = A extends B ? B extends A ? true : false : false
type NOT<A>=A extends false? true: false
type Include<list, item> = list extends [] ? false : list extends [infer x, ...infer xs] ? item extends x ? true : Include<xs, item> : false
type list = ['a', 'b', 'c']
function f<A, B>(a:A, b:B) where [Include<list, A>, Include<list, B>, NOT<EQ<A,B>>] { ... }
My workaround now is to do a type check on the return value of the function and return never
if it doesn't match the condition:
type EQ<A, B> = A extends B? B extends A? true: false: false
function f<A, B>(a:A, b:B): EQ<A,B> extends true? string: never { ... }
But this has many problems.
First, the return value is inferred as a conditional type and I need to convert it manually:
type EQ<A, B> = A extends B? B extends A? true: false: false
function f<A, B>(a:A, b:B): EQ<A,B> extends true? string: never { return 'a' as any }
Second, when the wrong parameter is entered, the type of the return value is never
, which is different from reporting an error.
Although most of the time, when I try to use a value of type never
, I get an error.
Duplicate #42388
@RyanCavanaugh I don't think #42388 is the same. The only thing it has in common is use of the where
keyword; otherwise, 42388 just looks like an alternative syntax for specifying upper bounds for individual type parameters, while this one is asking to use the where
clause to implement arbitrary constraints via conditional types.
For what it's worth, Turing complete constraints sound awesome on the surface, but in practice I think they will end up having limited appeal. This works great in first-order situations where the compiler can directly evaluate the conditional type in the where
clause, but because unbound type parameters are largely treated nominally, it will tend to fall over in higher-order situations:
function foo<T, U> where Equals<T, U>(x: T, y: U) {
// ...
}
function bar<T, U> where Equals<T, U>(x: T, u: U) {
foo(x, y); // error, distinct type parameters never compare equal
}
And that's assuming the conditional type isn't just completely deferred, in which case the only safe thing for the compiler to do is to reject the call.
@RyanCavanaugh I've seen this question and I don't think it's quite the same as my suggestion. #42388 is more like putting generic constraints in a unified place, while what I expected was describing conditional constraints between generics.
@fatcerberus You're right.
Yes, I also don't expect it to work when the generic type cannot be determined.
But in fact, if a generic type doesn't have the extends
constraint, then we won't be able to do anything with the value of that type, which is rare.
So generics are usually used with the extends
constraint, in which case the call condition can be determined from the extends
constraint.
@lsby Adding an extends
constraint doesn’t invalidate my concern. The issue is when one generic function calls other ones, in which case TS can’t verify the where
clause for the inner call(s). If TS defaults to allowing the call in this case just because the extends
constraints are met, then that’s not really useful because then the constraint effectively becomes T extends U OR where P<T>
. I’d expect it to be an AND
there instead.
@fatcerberus Oh! You are right!
In the case of generic functions calling other generic functions, denying the call is really the only safe way. I'll give an easier-to-understand example:
type IsLT5<A> = ...
type IsLT10<A> = ...
function f1<A extends number> where [IsLT5<A>](x: A) {
// ...
}
function f2<A extends number> where [IsLT10<A>](x: A) {
f1(x)
}
Suppose we have two types of calculations, IsLT5
and IsLT10
, means less than 5 and less than 10.
For example, IsLT5<1>
gets true
and IsLT5<6>
gets false
.
For f1(x)
, it is unsafe to consider only extends
, such as the case where x is 6.
This is really unsatisfactory, but difficult to achieve by narrowing down the generic type with the where
field.
A possible method is to analyze the calling process of the function,
and when it is found that x
in f2
calls f1
,
copy the where
condition of f1
on this parameter to f2
,
That is, TS first converts this code internally to:
type IsLT5<A> = ...
type IsLT10<A> = ...
function f1<A extends number> where [IsLT5<A>](x: A) {
// ...
}
function f2<A extends number> where [IsLT10<A>, IsLT5<A>](x: A) {
f1(x)
}
But this seems to complicate the implementation.
I think the easiest way is to not allow the value of the generic type to call a function with a where
field,
but allow the programmer to ignore the where
check by casting, for example:
type IsLT5<A> = ...
type IsLT10<A> = ...
function f1<A extends number> where [IsLT10<A>](x: A) {
// ...
}
function f2<A extends number> where [IsLT5<A>](x: A) {
f1(x as any)
}
In this case, f1(x)
is safe, because numbers less than 5 must also be less than 10.
But it is difficult for TS to know this.
We can specify that the where
check always allows values of type any
.
This allows the programmer to specify x
as type any
to ignore the where
check.
Of course, this requires the programmer to know what himself is doing.
This may not be elegant, but I think it's useful, such type conversions do not pollute the outside of the function, the programmer just has to make sure not to make mistakes here.