-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature Request: "extends oneof" generic constraint; allows for narrowing type parameters #27808
Comments
Sounds like you want an "exclusive-or" type operator - similar to #14094. |
I don't think that's it since function smallest<T extends string>(x: T[]): T;
function smallest<T extends number>(x: T[]): T; But not |
|
No because if |
True! No I wasn't, but I had parsed it in my head like |
What about a XOR type operator? This would be useful in other scenarios as well. As function smallest<T extends string ^ number>(x: T[]): T {
if (x.length == 0) {
throw new Error('empty');
}
return x.slice(0).sort()[0];
} |
@michaeljota See previous comments for why that doesn't work. There is no type today that could be put inside the This has to be solved by a different kind of constraint rather than a different type in the |
You want one of a giving list of types, right? I really don't know much about types, sorry. Still, OR operator can be satisfied with n types of the interception, and a XOR operator should only allow one of the types in the interception. I understand that if you create a interception, That's not what you would like this to do? |
The problem with that approach is that the For example, what should var x: string ^ number = 4; do? Are you allowed to reassign it to var x: Array<string ^ number> = [2, 5, "hello"]; why should/shouldn't this be allowed? There's no coherent way that this can be done. The only way to give it meaning is to only allow it in type Foo<T extends A ^ B> = {}
// means the same thing as
type Foo<T extends_oneof(A, B)> = {} It still represents a new type of constraint, not a type operator (because it doesn't produce coherent types). |
I think it should. In this case, I guess this would behave the same as
Here it would be useful, as you either want an array of strings, or an array of numbers, but not both. I think, this would be something like: var x: Array<string ^ number> = [2, 5, 'hello']
~~~~~~~~~~~~~~~ // Error: Argument of type '(string | number)[]' is not assignable to parameter of type 'string[] | number[]'.
// So (string ^ number)[], would be string[] | number[] |
This would make #30769 less of a bitter pill to swallow... |
And would solve #13995, right? |
@jcalz It "solves" it in the sense that (with other compiler work) the following code would compile like you'd expect: declare function takeA(val: 'A'): void;
export function bounceAndTakeIfA<AB extends_oneof('A', 'B')>(value: AB): AB {
if (value === 'A') {
// we now know statically that AB extends 'A'
takeA(value);
return value;
}
else {
// we now know statically that AB does not extend 'A', so it must extend 'B'
return value;
}
} It should be noted that besides the actual implementation of This feature makes it possible to soundly refine the constraints for type parameters, but actually doing that refinement would be a bit more work. |
A first class existential typeI think I have been conflating my Unions really are universal quantifiers over the set of their constituents, and oneofs are a separate but similar thing: existential quantifiers over the set of their constituents. With my current type Foo = oneof (A | B) | oneof (C | D)
type Bar = oneof ((A | B) | (C | D)) // won't work, will collapse I based everything around this A ^ B ^ C ^ D extends (A | B) ^ (C | D)
// => true, all of the left side constituents can find one on the right that they are a subtype of
(A | B) ^ (C | D) extends A ^ B ^ C ^ D
// => false, none of these left side constituents are subtypes of the right side constituents So basically @michaeljota was right with their suggestion all along, it's just that the semantics of this What are So now I have a new task on my list, after I finish implementing inference:
Currently the union/intersection types in the compiler look like this:
I plan to change this to this:
This way I can reuse all the collapsing and other "treat constituents as a set" logic from unions and implementation should be rather smooth. I am unsure about the renaming part since it will cause a huge amount of changed LoC (even though it's just a find all and replace for me) and might make it harder to maybe have an eventual PR merged, but if I don't then the naming will be confusing. We'll have to see. I prefer having a clean code structure I think. |
What to do hereA bit longer than a week ago I presented a (back then) strange and confusing observation I ran into in a channel in the TS community discord. One of the other users helpfully responded to my observation and since then I have been a bit stuck on this project. Not because I realized I was completely on the wrong path, but because their comment pointed out a situation where a choice needs to be made, and I'm stuck on this choice, stuck on which one is the "best" or "correct" one. Since then it's been 2 weeks of very little progress, so I thought it might be better to try writing down my thoughts. Maybe someone else could chime in, or maybe writing everything down could bring me to the correct conclusion through some form of rubber ducking. So here we go. Getting up to speedThe core problem we're trying to solve with the string[] | number[] extends (infer T)[] Currently the typescript compiler will infer string[] | number[] equals (infer T)[] The reason why becomes clear when you look at this code: type A = string[] | number[] extends (string | number)[] ? true : false
// ^? true
type B = (string | number)[] extends string[] | number[] ? true : false
// ^? false There is a subtle difference between The same difference exists when you use a simpler type composition like an object property, but it's even more subtle: type A = { foo: string } | { foo: number } extends { foo: string | number } ? true : false
// ^? true
type B = { foo: string | number } extends { foo: string } | { foo: number } ? true : false
// ^? false
So when we are trying to solve the There is a contravariant complement of this pattern, with intersections: type A = string[] & number[] extends (string & number)[] ? true : false
// ^? false
type B = (string & number)[] extends string[] & number[] ? true : false
// ^? true
type C = { foo: string } & { foo: number } extends { foo: string & number } ? true : false
// ^? false
type D = { foo: string & number } extends { foo: string } & { foo: number } ? true : false
// ^? true In this case the difference is even more subtle because in practice I can't immediately think of a situation where this difference is significant except in situations where a contravariant interaction places us back into the union version, but it's there. It may not immediately be apparent why all of this is related to the type MyFn1 = (x: A | B) => void But what we actually want to define is this type: interface MyFn2 {
(x: A): void
(x: B): void
}
// or:
type MyFn2 = ((x: A) => void) & ((x: B) => void) Note that type A = ((x: A | B) => void) extends ((x: A) => void) & ((x: B) => void) ? true : false
// ^? true And note how this pattern looks extremely similar to the patterns above. That is because it is the exact same pattern. What is the issue that has me blocked?(TBC...) |
@Nathan-Fenner Function overloads seem to perfectly solve the example given, and for generic type aliases which don't have overloads, with a little elbow grease you can use conditional types: // spurious tuple required to workaround distributivity of conditional types
type OneofStrNum<T extends [string | number]> = T[0] extends string ? string : T[0] extends number ? number : never
type MonomorphicArray<T extends string | number> = OneofStrNum<[T]>[]
type M1 = MonomorphicArray<string>
// ^? type M1 = string[]
type M2 = MonomorphicArray<number>
// ^? type M2 = number[]
type M3 = MonomorphicArray<string | number>
// ^? type M3 = never[] Sure it sucks but the main use case is functions anyway right? Can you motivate some non-function use cases so important that they deserve special syntax sugar for what can already be done with conditional types? Otherwise can we close this? |
This port concern TypeScript behavior and are not at all a criticism of you or your ideas. 1As you said, the typescript compiler will infer
But if we write it as a type function,
it does not lose precision. For the distribution over the union functionality to work, the function form is required. 2Another issue: there is some strange TypeScript behavior with the
TypeScript seems to delay the calculation of
ConclusionTypeScript handling of types at sets is not really mathematically precise. |
I think that this cannot be closed yet. It is unsolved. @laughinghan, your given example fixes the problem of using a function that allows T1 or T2 but not T1|T2 union. In my opinion that is not the problem (because it has a solution). The problem is implementing that function. Inside that function you cannot have a generic type See my example in my previous comment: #27808 (comment) |
@emilioplatzer Thank you, that's a much better example. Can we update the main issue description then? Both to mention the better example, and to mention that function overloads are an (incomplete) workaround. The fact that the best workaround requires annoying casts is a pretty good argument for this feature: function add(a: number, b: number): number
function add(a: bigint, b: bigint): bigint
function add<Num extends bigint | number>(a: Num, b: Num): bigint | number {
return (a as number) + (b as number);
} More generally TypeScript doesn't really track correlations between types much at all (with distributive conditional types being a very limited exception); this does seem like potentially a small, practical way to specify type correlations in some useful cases. |
@laughinghan @emilioplatzer @Nathan-Fenner There are really 3 separate items that are related:
Item 3 is already working, item 2 is not. Notice that @emilioplatzer post says
That is talking about item 3. The OP #27808 title implies that a new form for specifying correlations (
|
Isn't it just a syntax sugar for writing more function overloads? You can achieve the same functionality with a few type helpers: (Thanks to @jcalz for UnionToIntersection magic on stackoverflow) HelpersDefinitiontype UnionToIntersection<U> =
(U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never
// Here you define how your function overload will look
type Render<OneOfPossibleOptions> = <const T extends OneOfPossibleOptions>(x: T[]) => T
type ConvertTupleOfPossibleOptionsToOverloadsUnion<
TupleOfPossibleOptions
> = TupleOfPossibleOptions extends [infer OneOfPossibleOptions, ...infer RestOfPossibleOptions]
?
| Render<OneOfPossibleOptions>
| ConvertTupleOfPossibleOptionsToOverloadsUnion<RestOfPossibleOptions>
: never;
type ConvertTupleOfPossibleOptionsToOverloadsIntersection<
TupleOfPossibleOptions
> = UnionToIntersection<
ConvertTupleOfPossibleOptionsToOverloadsUnion<
TupleOfPossibleOptions
>
>; Tests and usageimport { Equals, assert } from 'tsafe';
// since ts doesn't have a way to reliably compare equality of types
// (including comparing overloads like `{
// (_: ('A' | 'B')[]): ('A' | 'B');
// (_: ('C' | 'D')[]): ('C' | 'D');
// }` to function intersections which I'm trying to do here), I wrote the
// expected test value in `((...) => ...) & ((...) => ...)` form instead of
// overload type form like `{(...) => ...; (...) => ...}`
// https://github.com/microsoft/TypeScript/issues/48100
// https://github.com/microsoft/TypeScript/issues/27024
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' | 'B', 'C' | 'D']>,
& (<const T extends ('A' | 'B')>(_: T[]) => T)
& (<const T extends ('C' | 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' , 'C' | 'D']>,
& (<const T extends ('A' )>(_: T[]) => T)
& (<const T extends ('C' | 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<[ 'B', 'C' | 'D']>,
& (<const T extends ( 'B')>(_: T[]) => T)
& (<const T extends ('C' | 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' | 'B', 'C' ]>,
& (<const T extends ('A' | 'B')>(_: T[]) => T)
& (<const T extends ('C' )>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' , 'C' ]>,
& (<const T extends ('A' )>(_: T[]) => T)
& (<const T extends ('C' )>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<[ 'B', 'C' ]>,
& (<const T extends ( 'B')>(_: T[]) => T)
& (<const T extends ('C' )>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' | 'B', 'D']>,
& (<const T extends ('A' | 'B')>(_: T[]) => T)
& (<const T extends ( 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' , 'D']>,
& (<const T extends ('A' )>(_: T[]) => T)
& (<const T extends ( 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<[ 'B', 'D']>,
& (<const T extends ( 'B')>(_: T[]) => T)
& (<const T extends ( 'D')>(_: T[]) => T)
>> Example 1Definitionconst fruits = ['apple', 'orange', 'banana'] as const satisfies unknown[]; // to remove readonliness
const colors = ['red', 'green', 'blue' ] as const satisfies unknown[]; // to remove readonliness
type FruitsUnion = typeof fruits[number];
type ColorsUnion = typeof colors[number];
const getRandomElementFromEitherArrayOfFruitsOrColors:
ConvertTupleOfPossibleOptionsToOverloadsIntersection<[FruitsUnion, ColorsUnion]>
= <const T extends FruitsUnion[] | ColorsUnion[]>(arr: T) => {
const doesArrHave = <T extends unknown[]>(valuesToLookFor: T) => arr.some((x) => valuesToLookFor.includes(x) )
const doesArrHaveOnly = <T extends unknown[]>(valuesToLookFor: T) => arr.every((x) => valuesToLookFor.includes(x) )
if (doesArrHave(fruits) && doesArrHave(colors))
throw new Error('Mixing fruits with Colors is not allowed')
if (!doesArrHave(fruits) && !doesArrHave(colors))
throw new Error('getRandomElementFromEitherArrayOfFruitsOrColors has nothing to choose from.')
if (
(doesArrHave(fruits) && !doesArrHaveOnly(fruits))
||
(doesArrHave(colors) && !doesArrHaveOnly(colors))
)
throw new Error("getRandomElementFromEitherArrayOfFruitsOrColors takes either only fruits or only colors")
const randomElement = arr[Math.floor(Math.random() * arr.length)];
return randomElement as T[number];
} Tests and usage// Maria likes apples more, so we increase it's probability, and she also
// hates oranges, so we remove them
const getRandomFruitForMaria =
() => getRandomElementFromEitherArrayOfFruitsOrColors(['apple', 'apple', 'apple', 'banana']);
assert<Equals<typeof getRandomFruitForMaria, () => 'apple' | 'banana'>>;
// John has no preference for colors
const getRandomColorForJohn = () => getRandomElementFromEitherArrayOfFruitsOrColors(colors);
assert<Equals<typeof getRandomColorForJohn, () => 'red' | 'green' | 'blue'>>;
try {
// @ts-expect-error Expected 1 arguments, but got 0.ts(2554)
getRandomElementFromEitherArrayOfFruitsOrColors()
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
}
try {
// @ts-expect-error No overload matches this call.
// Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
// Type '"asd"' is not assignable to type '"apple" | "orange" | "banana"'.
// Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
// Type '"asd"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
getRandomElementFromEitherArrayOfFruitsOrColors(['asd'])
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
}
// should not throw errors
getRandomElementFromEitherArrayOfFruitsOrColors(['apple'])
// should not throw errors
getRandomElementFromEitherArrayOfFruitsOrColors(['red'])
try {
// @ts-expect-error No overload matches this call.
// Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
// Type '"red"' is not assignable to type '"apple" | "orange" | "banana"'.
// Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
// Type '"apple"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
getRandomElementFromEitherArrayOfFruitsOrColors(['apple', 'red'])
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
}
try {
// @ts-expect-error No overload matches this call.
// Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
// Type '"red"' is not assignable to type '"apple" | "orange" | "banana"'.
// Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
// Type '"apple"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
getRandomElementFromEitherArrayOfFruitsOrColors(['red', 'apple'])
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
} Example 2 (to show applicability to the OP's example)Definitionconst getSmallestElement: ConvertTupleOfPossibleOptionsToOverloadsIntersection<[string, number]> =
<T extends string[] | number[]>(arr: T) => {
const doesArrHaveValuesConforming = <T extends unknown[]>(rule: (t: unknown) => t is T[number]) => arr.some((x) => rule(x) )
const doesArrHaveOnlyValuesConforming = <T extends unknown[]>(rule: (t: unknown) => t is T[number]) => arr.every((x) => rule(x) )
const doesArrayHasStrings = doesArrHaveValuesConforming(t => typeof t === 'string');
const doesArrayHasNumbers = doesArrHaveValuesConforming(t => typeof t === 'number');
const doesArrayHasOnlyStrings = doesArrHaveOnlyValuesConforming(t => typeof t === 'string');
const doesArrayHasOnlyNumbers = doesArrHaveOnlyValuesConforming(t => typeof t === 'number');
if (doesArrayHasStrings && doesArrayHasNumbers)
throw new Error('Mixing strings with numbers is not allowed')
if (!doesArrayHasStrings && !doesArrayHasNumbers)
throw new Error('getSmallestElement has nothing to choose from.')
if (
(doesArrayHasStrings && !doesArrayHasOnlyStrings)
||
(doesArrayHasNumbers && !doesArrayHasOnlyNumbers)
)
throw new Error("getSmallestElement takes either only strings or only numbers")
return arr.toSorted()[0] as T[number];
} Tests and usageconst smallestOf6Numbers =
getSmallestElement([4, 0, 1, 2, 3, 4])
assert<Equals<typeof smallestOf6Numbers, 0 | 2 | 1 | 3 | 4>>(smallestOf6Numbers === 0);
const smallestOf6Strings =
getSmallestElement(['4', '0', '1', '2', '3', '4'])
assert<Equals<typeof smallestOf6Strings, '0' | '1' | '2' | '3' | '4'>>(smallestOf6Strings === '0');
const smallestOf6Strings2 =
getSmallestElement(['a', 'b', 'c', 'd', 'e', 'f'])
assert<Equals<typeof smallestOf6Strings2, 'a' | 'b' | 'c' | 'd' | 'e' | 'f'>>(smallestOf6Strings2 === 'a');
try {
// @ts-expect-error Expected 1 arguments, but got 0.ts(2554)
getSmallestElement()
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
}
try {
// @ts-expect-error No overload matches this call.
// Overload 1 of 2, '(x: string[]): string', gave the following error.
// Type 'boolean' is not assignable to type 'string'.
// Overload 2 of 2, '(x: number[]): number', gave the following error.
// Type 'boolean' is not assignable to type 'number'.ts(2769)
getSmallestElement([false])
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
}
// should not throw errors
getSmallestElement(['000'])
// should not throw errors
getSmallestElement([0])
try {
// @ts-expect-error No overload matches this call.
// Overload 1 of 2, '(x: string[]): string', gave the following error.
// Type 'number' is not assignable to type 'string'.
// Overload 2 of 2, '(x: number[]): number', gave the following error.
// Type 'string' is not assignable to type 'number'.ts(2769)
getSmallestElement(['000', 0])
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
}
try {
// @ts-expect-error No overload matches this call.
// Overload 1 of 2, '(x: string[]): string', gave the following error.
// Type 'number' is not assignable to type 'string'.
// Overload 2 of 2, '(x: number[]): number', gave the following error.
// Type 'string' is not assignable to type 'number'.ts(2769)
getSmallestElement([0, '000'])
console.error("Failed to fail :(")
} catch (error) {
console.info('Successfully failed :)',error)
} |
@nikelborm excellent answer. here's a slightly more flexible version that would accept the render method as parameter:
|
@nikelborm and @TheRealMkadmi: I don't have time to test out your implemenations, but it's cool to hear that this is already possible to some extent. Could I interest you in contributing to https://github.com/sindresorhus/type-fest/ so that it's easy for others to use your implementations? 😄 |
Search Terms
Suggestion
Add a new kind of generic type bound, similar to
T extends C
but of the formT extends oneof(A, B, C)
.(Please bikeshed the semantics, not the syntax. I know this version is not great to write, but it is backwards compatible.)
Similar to
T extends C
, when the type parameter is determined (either explicitly or through inference), the compiler would check that the constraint holds.T extends oneof(A, B, C)
means that at least one ofT extends A
,T extends B
,T extends C
holds. So, for example, in a functionJust like today, these would be legal:
But (unlike using
extends
) the following would be illegal:Use Cases / Examples
What this would open up is the ability to narrow generic parameters by putting type guards on values inside functions:
This can't be safely done using
extends
, since looking at one item (even if there's only one item) can't tell you anything aboutT
; only about that object's dynamic type.Unresolved: Syntax
The actual syntax isn't really important to me; I just would like to be able to get narrowing of generic types in a principled way.
(EDIT:)
Note: despite the initial appearance,
oneof(...)
is not a type operator. The abstract syntax parse would be more likeT extends_oneof(A, B, C)
; theoneof
and theextends
are not separate.Checklist
My suggestion meets these guidelines:
(any solution will reserve new syntax, so it's not a breaking change, and it only affects flow / type narrowing so no runtime component is needed)
The text was updated successfully, but these errors were encountered: