Open Nathan-Fenner opened 6 years ago
@craigphicks, I think you are right. Now we can use a workaround, some easy script (at npm build or whereever) that do the job.
These days we can consider that there are 3 check times:
With this workaround we have the firts two (the most importants).
I hope typescript adds this proposal to have the third one (and to not have to go around with workarounds).
For still thinking.
Here is a 3 check times ugly solution. playground
It checks at edigint time, with minimal // @ts-ignore
s, but it needs to write maths in a ugly way:
type Num<T extends number|bigint> = {obscure:"implementation", doNotUse:"Num literals", internal:T};
// @ts-expect-error UNIQUE ignore error!
var num:<T extends number|bigint>(a:T)=>Num<T> = a => a;
// UNIQUE set of "any":
var operators={
"+": (a:any, b:any) => a + b,
"-": (a:any, b:any) => a - b,
"*": (a:any, b:any) => a * b,
"/": (a:any, b:any) => a / b,
}
function operate<T extends number|bigint>(factor1:Num<T>, operator:keyof typeof operators, factor2:Num<T>):Num<T>{
return operators[operator](factor1, factor2)
}
console.log(operate(num(3),"*",num(2)));
console.log(operate(num(2n),"+",num(3n)));
// OK, error:
// console.log(operate(num(2),"+",num(3n)));
function getSquareDif<T extends number|bigint>(a:Num<T>, b:Num<T>):Num<T>{
return operate(operate(a,"*",a),"-",operate(b,"*",b));
}
console.log(getSquareDif(num(4),num(1)))
console.log(getSquareDif(num(3n),num(1n)))
// OK, error:
// console.log(getSquareDif(num(3n),num(1)))
function f<T extends number|bigint>(a:Num<T>, b:Num<T>, c:Num<T>):Num<T>{
return operate(getSquareDif(a,b),"/",c);
}
console.log(f(num(3), num(1), num(2)));
console.log(f(num(3n), num(1n), num(2n)));
// OK, error:
// console.log(f(num(3n), num(1n), num(2)));
It is not complete. Unary operators, constants and function calls will be resolved in a similar way.
Also it is posible to optimize in "build-time" replacing "num" with "", "operate" with "" and /,"([-+/*])",/ with "$1" (the last is not safe, it can be safer using unique consts instead of string literals, "+", "-", etc.)
I'm highly in favor of such a feature. I came across a similar issues a few days ago. There is just no way to tell TS that a generic can only be ONE of an union type and NOT a subset union type.
Take the following example:
type Event1 = { foo: 1 };
type Event2 = { bar: 2 };
type Event3 = { meh: 2 };
interface EventMap {
event1: Event1,
event2: Event2,
event3: Event3,
}
function doStuff <T extends keyof EventMap> (type: T, event: EventMap[T]) {
// TODO: Implement
}
// this is fine
doStuff('event1', { foo: 1 }); // ✔️
doStuff<'event1'|'event2'>('event2', { foo: 1 }); // ❌
// -> technically 'event1'|'event2' extends keyof EventMap
// -> EventMap['event1'|'event2'] = Event1|Event2
// -> This is valid :(
Of course this example is just kinda stupid, but it that was the simplest example I could come up with.
That being said: My use-case is obviously a lot more complex and nested, so that TS can't just infer the type and therefore just expects any possible combination of keys of EventMap
If I could specify for the generic, that only a single key of EventMap
is valid for, this wouldn't be an issue. I was imagining just a new utility type (OneOf
or whatever), which takes a union type and matches any type in that union type, but not a subset with multiple types:
function doStuff <T extends OneOf<keyof EventMap>> (type: T, event: EventMap[T]) {
But tbh I don't know how complex any of the proposed solutions are. I would just be happy with any of those
@DerZade
Take the following example:
type Event1 = { foo: 1 }; type Event2 = { bar: 2 }; type Event3 = { meh: 2 }; interface EventMap { event1: Event1, event2: Event2, event3: Event3, } function doStuff <T extends keyof EventMap> (type: T, event: EventMap[T]) { // TODO: Implement } // this is fine doStuff('event1', { foo: 1 }); // ✔️ doStuff<'event1'|'event2'>('event2', { foo: 1 }); // ❌ // -> technically 'event1'|'event2' extends keyof EventMap // -> EventMap['event1'|'event2'] = Event1|Event2 // -> This is valid :(
Generic function overloads can solve your problem. Oneof might be a nice syntactic shortcut, but it is already possible with overloads -
type Event1 = { foo: 1 };
type Event2 = { bar: 2 };
type Event3 = { meh: 2 };
interface EventMap {
event1: Event1,
event2: Event2,
event3: Event3,
}
function doStuff(type: 'event1', event: EventMap['event1']):void;
function doStuff(type: 'event2', event: EventMap['event2']):void;
function doStuff(type: 'event3', event: EventMap['event3']):void;
function doStuff<T extends keyof EventMap>(type: T, event: EventMap[T]){
console.log({type,event})
}
// this is fine
doStuff('event1', { foo: 1 }); // ✔️
/* @ ts-expect-error */
doStuff('event2', { foo: 1 }); // ❌ THIS IS NOW AN ERROR
There is also a way to generate the constraints automatically with function types, without using overloads. This is great because it generalizes well when EventMap has many keys.
type ToughStew<K extends keyof EventMap> = (type: K, event:EventMap[K])=>void
type ToughStewAny_<K extends keyof EventMap> = K extends any ? ToughStew<K> : never;
type ToughStewAny = ToughStewAny_<keyof EventMap>
function toughStewAny<F extends ToughStewAny>(...args:Parameters<F>):void{
const [type,event]=args
console.log({type,event});
}
toughStewAny('event1',{foo:1}); // ✔️
toughStewAny('event2',{foo:1}); // ❌ THIS IS AN ERROR
@DerZade
Generic function overloads can solve your problem.
Oneof might be a nice syntactic shortcut, but it is already possible with overloads -
type Event1 = { foo: 1 }; type Event2 = { bar: 2 }; type Event3 = { meh: 2 }; interface EventMap { event1: Event1, event2: Event2, event3: Event3, } function doStuff(type: 'event1', event: EventMap['event1']):void; function doStuff(type: 'event2', event: EventMap['event2']):void; function doStuff(type: 'event3', event: EventMap['event3']):void; function doStuff<T extends keyof EventMap>(type: T, event: EventMap[T]){ console.log({type,event}) } // this is fine doStuff('event1', { foo: 1 }); // ✔️ /* @ ts-expect-error */ doStuff('event2', { foo: 1 }); // ❌ THIS IS NOW AN ERROR
More extensible way - but harder to understand and possibly fragile.
There is also a way to generate the constraints automatically with function types,
without using overloads.
This is great because it generalizes well when EventMap has many keys.
type ToughStew<K extends keyof EventMap> = (type: K, event:EventMap[K])=>void type ToughStewAny_<K extends keyof EventMap> = K extends any ? ToughStew<K> : never; type ToughStewAny = ToughStewAny_<keyof EventMap> function toughStewAny<F extends ToughStewAny>(...args:Parameters<F>):void{ const [type,event]=args console.log({type,event}); } toughStewAny('event1',{foo:1}); // ✔️ toughStewAny('event2',{foo:1}); // ❌ THIS IS AN ERROR
Thanks for the tips.
I knew about the first solution, tho like you already said it's not really viable if EventMap has many keys (which is true in my case. I think I'm at 60+ keys).
I'm pretty sure the second solution won't work for me as well. The example I gave is obviously simplified by a lot and in my actual case doStuff
does not get the type and event as parameters, but calculates them over multiple steps. Multiple people in the TS Discord confirmed, that my issue is not easily solvable as well. Nevertheless I will try to apply the second solution to my problem in the coming days.
Either way I think we can agree that tackling such a problem would be so much easier and cleaner with one of. I'm convinced that it would be a nice addition to the language, even if most use-cases, that were brought up here, are solvable already. If we're able to introduce it in a non-breaking way there would be no harm done imho.
Regarding the syntax (from a TS noob perspective): As I already suggested, I would appreciate a "simple" utility type, which constructs a new type from a UnionType, that matches each element of the union, but not a "sub-union". This would extend the use-cases further then just generics and it would be fairly easy to understand without learning a "new" syntax.
Either way I think we can agree that tackling such a problem would be so much easier and cleaner with one of ... I would appreciate a "simple" utility type, which constructs a new type from a UnionType, that matches each element of the union, but not a "sub-union"
Actually, for a very specific reason, I currently don't agree. I will explain that reason: Consider
function f<T extends 1|2>(a: T){}
The value of 'a' can never be 1
and 2
at the same time.
Also in the case of
function f<T extends 1|2>(a: T, b:T){ if(a!==b)throw "violation"}
the value of a
can never be 1
and 2
at the same time, and additionally
the value of b
can never be 1
and 2
at the same time.
But obviously a
and b
can be different.
Now suppose
type OneOrTheOther = oneofUtility(1|2)
what are it's members? So we are back where we started.
On the other hand, if we talk about the valid types of the parameters list, it makes more sense. There are two types [a:1,b:1]
and [a:2,b:2]
. We are talking about what is between the round parens (...)
), rather than what is in the generic parameters list (i.e., what is between the angle parens <...>
). That is why I don't think the angle parens <...>
is the best place to put the directive.
Practically speaking, in Typescript that means either talking about overloads, or passing the parameter list as a generic type which constraints the parameters elements to be correctly typed with respect to each other.
overloads
function f(a:1,b:1);
function f(a:2,b:2);
function f<T extends 1|2>(a:T,b:T){if(a!==b)throw "never"}
parameter list types
type Params<T extends 1|2> [a:T,b:T];
type Params1<T extends 1> [a:T,b:T];
type Params2<T extends 2> [a:T,b:T];
function f<P extends Params1|Params2>(...p:P){
let [a,b]=p;
if(a!==b)throw "never"
}
Fundamentally they are doing the same thing but with different mechanics.
Either way there are cases, like yours, where the number of members in the set are large enough that automation is required.
That automation syntax should be explicit, simple and easy to understand. Some automation method(s) already exists - are they enough? (No - way too obscure.)
@DerZade
Here is another automated way, this time with overloads -
///////////////////////////////////////////////////////////////////////
// Automated way via overloads in an I/F with template literal types
// Derived from
type Overloads_<M extends EventMap, K extends keyof EventMap> = {
<K extends keyof EventMap>
(type: `${K}`, event:M[`${K}`]):void;
};
type Overloads = Overloads_<EventMap, keyof EventMap>
const duneCruft:Overloads=<T extends keyof EventMap>(type: T, event: EventMap[T])=>{
console.log({type,event})
}
duneCruft('event1',{foo:1}) // ✔️
duneCruft('event2',{foo:1}) // ❌ THIS IS AN ERROR
See about template literal types, in particular, "PropEventSource". playground
That's pretty crufty syntax isn't it? Not something that flows off the fingers.
How about this for an interface?
function myfunc<T extends XXX>[P is (t:T,u:XXX)](...args:P){
let [t,u] = args;
...
}
where [...]
is a space to define the overloads, and where any type from the l.h.s. of an extends in the <...>
part is assumed to be a 'oneof'. Then there is room for variation. E.g., suppose that the type of u
depends upon T
according to a helper function type H<T extends XXX>= ...
. Then we could write
type H<T extends XXX>= ...
function myfunc<T extends XXX>[P is (t:T,u:H<T>)](...args:P){
let [t,u] = args;
...
}
It's only meant as food for thought - not a solid proposal.
Actually, for a very specific reason, I currently don't agree. I will explain that reason: Consider
function f<T extends 1|2>(a: T){}
The value of 'a' can never be
1
and2
at the same time. Also in the case offunction f<T extends 1|2>(a: T, b:T){ if(a!==b)throw "violation"}
the value of
a
can never be1
and2
at the same time, and additionally the value ofb
can never be1
and2
at the same time.But obviously
a
andb
can be different.Now suppose
type OneOrTheOther = oneofUtility(1|2)
what are it's members? So we are back where we started.
In my (typescript noob) humble opinion this is absolutely fine:
function f<T extends oneofUtility(1|2)>(a: T, b: T){}
f(1, 1) // ✔️
f(2, 2) // ✔️
f(1, 2) // ❌
f(2, 1) // ❌
You have to keep in mind that this one of is not the default behavior. It must be specifically specified by the user, and if it is, then it is an absolutely valid use case imho.
You can take this even to a more complex example, where something like this would be useful:
type Obj<T> = { foo: T };
function compare1<T extends string|number>(a: Obj<T>. b<T>){}
function compare2<T extends oneofUtilty(string|number)>(a: Obj<T>. b<T>){}
compare1({ foo: 'str' }, { foo: 3 }) // ✔️
compare2({ foo: 'str' }, { foo: 3 }) // ❌
@DerZade
You are answering my question "what are its members" with the answer "this is a simple solution", and I appreciate that, because simple is good.
Surely it would be simple solution to a bunch of simple problems. But it is
But it would cause other new problems, and leave other unsolved problems unsolved.
The OP called it oneof not oneofUtility, but seeing it written where I expect a type to be - I initially thought this must be a type utility. And I tried to answer the question "what are the members of oneofUtility(1|2)?", and my head hurt. I can tell you one of the new problems would be answering the question "what are the members of oneofUtility(1|2)?", because a lot of users will ask that. Of course we can answer that with "What is the sound of one hand waving?".
Moreover, supposing what you want is
f(1, 1) // ✔️
f(2, 2) // ✔️
f(1, 2) // ✔️
f(2, 1) // ❌
and many other case not cleanly represented by oneofUtility
. I claim would easy enough to design something the expresses that oneofUtility functionality in a brief notation, but also extends gracefully to cover more cases.
Firstly I would suggest putting the magic in a separate space:
function f<T extends 1|2>[/* magic space */](a: T, b: T){}
What would be the simplest way to represent the main case oneof
? That would be nothing at all []
. But if there were multiple template variables that would apply to all of them - not sure if that would be good or not. So lets just say [T]
.
function f<T extends 1|2>[T](a: T, b: T){}
f(1, 1) // ✔️
f(2, 2) // ✔️
f(1, 2) // ❌
f(2, 1) // ❌
To create the exception where f(1,2)
is allowable we add the exception case after a ;
character:
function f<T extends 1|2>[T;(a: 1, b: 2)](a: T, b: T){}
f(1, 1) // ✔️
f(2, 2) // ✔️
f(1, 2) // ✔️
f(2, 1) // ❌
The purpose of the ;
character is to divide mappings which come before the ;
, and exceptions which come after.
Here is a mapping example
function f<T extends 1|2>[T~(c);](a: T, b: T,c: T){}
f(1,1,1) // ✔️
f(1,1,2) // ✔️
f(1, 2, 1) // ❌
f(1, 2, 2) // ❌
f(2,1,1) // ❌
f(2,1,2) // ❌
f(2, 2, 1) // ✔️
f(2, 2, 2) // ✔️
which says that c
selects it's value of T
separately from that of a,b
. An equivalent notation would be
function f<T extends 1|2>[T~(a,b);](a: T, b: T,c: T){}
saying that the pair a,b
select their value of T
separately from that of c
. ([T~(a,b,c)]
would be equivalent to the oneof equivalent [T]
, and [T~(a),T~(b),T~(c)]
would be equivalent to removing the []
altogether.)
To that we can add a negative exception
function f<T extends 1|2>[T~(c);^(1,1,1)](a: T, b: T,c: T){}
f(1,1,1) // ❌
f(1,1,2) // ✔️
f(1, 2, 1) // ❌
f(1, 2, 2) // ❌
f(2,1,1) // ❌
f(2,1,2) // ❌
f(2, 2, 1) // ✔️
f(2, 2, 2) // ✔️
Obviously that's a lot more work than just the oneof equivalent [T]
, but it can be done in stages, releasing the oneof equivalent [T]
first, and extending according to need and other priorities. Because it is in a separate space it can be easily extended, (not necessarily to the specifications I wrote above - those are just illustrative examples).
To be clear I am in favor of a oneof equivalent, that is not written in the existing template parameter space, which is extremely simple and clear to understand, and which is future extendable to gracefully handle more nearby cases.
I'll reply to the second part of your comment in another comment.
You can take this even to a more complex example, where something like this would be useful:
type Obj<T> = { foo: T }; function compare1<T extends string|number>(a: Obj<T>. b<T>){} function compare2<T extends oneofUtilty(string|number)>(a: Obj<T>. b<T>){} compare1({ foo: 'str' }, { foo: 3 }) // ✔️ compare2({ foo: 'str' }, { foo: 3 }) // ❌
I didn't understand that because the code didn't compile. I'll take a tangent and talk about something else, some existing typescript behavior -
type Obj<T> = { foo: T };
function compare1<T extends string|number>(a: Obj<T>, b:Obj<T>){}
function compare2<T extends Obj<string|number>>(a: T, b:T){}
function compare3<T extends Obj<string>|Obj<number>>(a: T, b:T){}
compare1({ foo: 'str' }, { foo: 3 }) // ❌
compare2({ foo: 'str' }, { foo: 3 }) // ✔️
compare3({ foo: 'str' }, { foo: 3 }) // ✔️
function compare11<T extends string|number>(a: T, b:Obj<T>){}
compare11('str' , { foo: 3 }) // ❌
compare11('str' , { foo: 'str' }) // ✔️
type Id<T> = T
function compare21<T extends string|number>(a: Id<T>, b:Id<T>){}
compare21('str', 'str'); // ✔️
compare21('str', 1); // ❌
function compare22<T extends Id<string|number>>(a: T, b:T){}
compare22('str', 'str'); // ✔️
compare22('str', 1); // ❌
function compare23<T extends Id<string|number>>(a: T[]){}
compare23(['str', 'str']); // ✔️
compare23(['str', 1]); // ✔️
function compare24<T extends string|number>(a: T[]){}
compare24(['str', 'str']); // ✔️
compare24(['str', 1]); // ✔️
In short: side effects can sometimes be used to force multiple parameters in a generic function to take the same choice of generic type - a oneof-like effect.
While that might be practically useful I do not think it is as good as an explicit notation embedded in the function definition.
Side effects are harder to understand, harder to reason about, and harder to remember. We probably agree about that. That's the core idea behind the "oneof" proposal - explicit notation - and that core idea is correct.
There is just no way to tell TS that a generic can only be ONE of an union type and NOT a subset union type.
@DerZade We can use conditional types to make sure a generic parameter is not a union.
type AssertsNonUnion<T> =
[T] extends [infer U] ? U extends unknown ? // Make T non-distributive, and (U, an alias for T) distributive
T extends U ? T : never
: never : never
;
And the example you posted in https://github.com/microsoft/TypeScript/issues/27808#issuecomment-1030390770 could be written in:
type Event1 = { foo: 1 };
type Event2 = { bar: 2 };
type Event3 = { meh: 2 };
interface EventMap {
event1: Event1,
event2: Event2,
event3: Event3,
}
function doStuff <T extends keyof EventMap> (type: AssertsNonUnion<T>, event: EventMap[typeof type]) {
// TODO: Implement
}
doStuff('event1', { foo: 1 }); // ✔️
doStuff<'event2' | 'event1'>('event2', { foo: 1 }); // correct error
@whzx5byb, It seems to me that is a partial solution. But may be I can't understand how to use it.
I have this example that uses the same parameter in more than one point
I try to use it in the definition of the type parameter and I try to use it in the definition of the function parameters but I don't get to work.
The summary is to can define math functions that can use either bigint or number (but not a union) in a complete and complex way:
function mult<T extends bigint|number)(a:T, b:T):T{
return a*b;
}
Note: of course this is a very simple function that may be have a workarround. In the examples that I link above I wrote a representative example. The problem is to use it inside functions. Here is other example (not bigint|number
).
@emilioplatzer
For you example, a most simple workaround is to define the constraint as union of array type.
declare function smallest<T extends string[] | number[]>(args: T): T[number]
smallest([0, 1]) // return number
smallest(['0', '1']) // return string
smallest(['0', 1]) // correct error
function mult<T extends bigint[] | number[]>(...[a, b]: T): T[number] {
// @ts-ignore-error
return a * b;
// or
// return ((a as any) * (b as any) as any)
}
mult(1, 2) // return number
mult(1n, 2n) // return bigint
mult(1, 2n) // correct error
However you still need @ts-ignore-error
or as any
inside the implementation.
.
@whzx5byb The problem is that, as you show, the workarround doesn't works inside the function (in line with return a * b;
). The workarround (and your AssertsNonUnion
) only works for function signature when calling functions. But doesn't work inside the functions. And when the function is complex it is a shame not to be able to control the types. That's why we need an "extends oneof".
It seems like CFA doesn't work for generic parameter.
// 【THIS WORKS !】
function test(v: 'a' | 'b') {
if (v === 'a') {
//@ts-expect-error
if (v === 'b') {
}
}
}
// 【WHY THIS DOESN'T WORKS ?】
function test2<T extends 'a' | 'b'>(v: T) {
if (v === 'a') {
//@ts-expect-error
if (v === 'b') {
}
}
}
I haven't seen a workaround which allows narrowing the return type yet, when the return type is also determined by the generic parameter? Is that possible?
I am currently working on a fork where I am trying to implement this feature, as I believe it is required to properly solve the 2 limitations of TypeScript that people encounter most often in the TypeScript Community Discord server's help section, where I am active.
I thought I would share my ideas here in order to get some feedback, especially since I have never worked on compilers before and could use any input I can get.
The main problem concerns mapping over a list of items where 2 different properties of the elements are related in some way to another.
const foo = [
{ obj: { bar: 5 }, key: 'bar' },
{ obj: { baz: 7 }, key: 'baz' },
] as const
declare function fn<K extends string, O extends Record<K, unknown>>(o: O, key: K): void
for (const f of foo) {
fn(f.obj, f.key) // Property 'baz' is missing in type '{ readonly bar: 5; }' but required in type 'Record<"bar" | "baz", unknown>'.
}
We are looping over all elements of foo
but when we access obj
and key
, TypeScript resolves the types to the union of
all their values in the union type of f
. f.obj
gets type { bar: 5 } | { baz: 7 }
, f.key
gets type 'bar' | 'baz'
.
We then try to call fn
with signature fn<'bar' | 'baz', { bar: 5 } | { baz: 7 }>
which fails completely.
There is a different problem that very often pops up which is related and could also be solved with the changes I am exploring.
type Fruit =
| { type: 'apple', stemmed: boolean }
| { type: 'banana', peeled: boolean }
function createFruit<T extends Fruit>(fruit: T['type']): T {
switch (fruit) {
case 'apple': return { type: 'apple', stemmed: false } // '{ type: "apple"; stemmed: false; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Fruit'.(2322)
case 'banana': return { type: 'banana', peeled: false }
default: throw new Error()
}
}
This construct seems at first glance to be valid for a developer (after all you can't provide a value for fruit
if the corresponding type is not in T), but it is not. This is because you could call this function as such:
createFruit<{ type: 'apple', stemmed: boolean, deseeded: boolean }>('apple')
and now the returned object is invalid for the type that it has.
The mapping problem stems from a simple root: TypeScript does not know that the types of obj
and key
are related and cannot figure out that there will only ever be 1 element of the union processed at the same time.
At first I thought this problem could be solved by simply deferring the type resolution of the properties instead of eagerly resolving them when they are accessed. However this would not suffice. When resolving the fn
call you would still instantiate fn
's type as
declare function fn<({ obj: { bar: 5 }, key: 'bar' } | { obj: { baz: 7 }, key: 'baz' })['key'], ({ obj: { bar: 5 }, key: 'bar' } | { obj: { baz: 7 }, key: 'baz' })['obj']>
and the same problem would occur.
Instead what is actually needed is for the type of f
to be a type parameter F
with a constraint with special semantics, namely that the instantiated type of F
will only ever be one element of the union typeof foo[number]
and that the type resolution of accesses should be deferred, so that when fn
is instantiated as fn<F['key'], F['obj']>
the typechecker knows to check the constraints of fn
's type parameters for each of the items in the union separately.
What is needed is for the type of f
to be a type parameter F extends oneof typeof foo
(or rather F equals oneof typeof foo
, but I'll discuss that below). Hence why I am posting this as a comment to this thread.
Whereas before constraints were just some type you could put behind an extends clause, I am attempting to make them more of a concept on itself to which modifiers can be applied. Whereas before the syntax for type parameter constraints was just like this:
const fn<K extends Keys>(k: K): void
^ ^- constraining type
|- extends keyword
I am changing them so that they work more like this:
const fn<K extends oneof Keys>(k: K): void
\___________/ ^- constraining type
|- modifiers
This way constraints can be a more general and extensible concept.
In terms of modifiers I am currently trying out 2 different "dimensions", which each have defaults:
extends
(default): Standard behavior. Type parameter can be instantiated with any subtype.equals
: The type parameter can only be instantiated with the type itself. This may at first seem useless but actually turns out to be really useful.allof
(default): Standard behavior. Union types are taken "as is". The type parameter can be instantiated with a union and this constraint does not distribute when checking types its associated type parameter is instantiated in.oneof
: The type parameter can only be instantiated with an element of the union, and this constraint distributes when checking types it is instantiated in.Transitivity modifiers need to come before distributivity modifiers and in type parameter declarations a transitivity modifier must be specified so as to not make syntax confusing. So you can't do any of these:
const fn<K Keys>(k: K): void
const fn<K allof Keys>(k: K): void
const fn<K oneof Keys>(k: K): void
Summarizing, any of the following combinations are valid:
const fn<K extends allof Keys>(k: K): void // Standard behavior. Can specify any subtype of Keys.
const fn<K extends oneof Keys>(k: K): void // Can only specify one of the union elements of Keys or their subtypes.
const fn<K equals allof Keys>(k: K): void // Can only specify exactly the type Keys (see the additional advantages section for how this is useful).
const fn<K equals oneof Keys>(k: K): void // Can only specify one of exactly the union elements of Keys.
const fn<K extends Keys>(k: K): void // Standard behavior. Can specify any subtype of Keys. (same as `extends allof`)
const fn<K equals Keys>(k: K): void // Can only specify exactly the type Keys. (same as `equals allof`)
In terms of solving the mapping problem, I believe that making the for (const f of foos) {
statement infer the type of f
to be a type parameter F equals oneof typeof foos
would completely solve this problem (F extends oneof typeof foos
would also work but the former is more semantically correct). The types of f.obj
and f.key
would be inferred as F['obj']
and F['key']
and at the fn
call site fn
would be instantiated as fn<F['obj'], F['key']>
. The type checker, while checking the constraints of fn
would then map over all the elements of the union F and check them all separately.
This would also solve the problem in the case where you use for instance the Array.map
function. Array.map
could be declared as follows:
interface Array<T> {
map<U>(callbackfn: <V equals oneof T>(value: V, index: number, array: readonly T[]) => U, thisArg?: any): U[];
}
For the second problem declaring the createFruit
function like this would make it clear to the compiler that
function createFruit<T equals oneof Fruit>(fruit: T['type']): T {
Aside from solving these problems, the transitivity dimension could also have additional advantages, for instance:
The equals
constraint modifier could allow developers to specify that the type of an options object passed to a function should be closed and no other properties should be allowed (for which we now have special rules governing object literals, which aren't very reliable and don't work for predefined types). It might also be useful for the same reason in combination with the satisfies
keyword.
type Options = { foo: number, bar: number }
const createBaz = <T equals Options>(options: T) => {}
// The following would not work as it would instead infer the type { foo: number, bar: number, goo: number }
// which does not satisfy the `equals` constraint.
createBaz({ foo: 5, bar: 2, goo: 3 })
// This would also not work for the same reason.
const staticOptions = { foo: 3, bar: 7, goo: 10 } as const satisfies equals Options
In the createBaz
example it should be noted however that this does not block normal type subsumption from occurring. Therefore it only has an effect for inferred instances of T:
// The following would still typecheck.
createBaz<Options>({ foo: 5, bar 2, goo: 3 })
// This would also still typecheck.
const o1 = { foo: 5, bar 2, goo: 3 }
createBaz<Options>(o1)
// This would also typecheck.
const o2: Options = { foo: 5, bar 2, goo: 3 }
createBaz(o2)
// Note that in all these cases, while correct type-wise, there will still be an error due to the superfluous property check.
I have noticed while continuing research on this that the idea of distributive constraints may actually overlap distributive conditional types. Currently whether a conditional type is distributive or not is decided by the rather arbitrary classifier of whether the type being checked against is a naked type parameter:
type MkFoo<T> = T extends U ? { foo: T } : never // distributive
type MkFoo<T> = [T] extends [U] ? { foo: T } : never // nondistributive
It may be possible to instead reuse the oneof
/ allof
modifier:
type MkFoo<T> = oneof T extends U ? { foo: T } : never // always distributive
type MkFoo<T> = allof T extends U ? { foo: T } : never // always nondistributive
Of course keeping the old behavior for unspecified constraint modifiers for the sake of backward compatibility.
The other constraint modifier of transitivity could also be useful to check for type equality, which is now often done with a complex utility type that abuses obscure mechanics of the type system:
type MkFoo<T> = T equals oneof U ? { foo: T } : never // checking equality with one of the constituents of U
type MkFoo<T> = T equals allof U ? { foo: T } : never // checking equality with U
I have only started work on this a few days ago, but I have already implemented most of the syntactic part. I have now started work on modifying the typechecker to handle these new flexible constraints. I do feel like I am on the right track though, as I have already encountered multiple instances of logic in the type checker that could be simplified and consolidated with these ideas.
Once we are using these flexible constraints, subtyping behavior for them may no longer be trivial. Let's look at this in detail. We'll look at different contexts, and every time we'll first have a look at the subtyping behavior of legacy constraints, then try to deduce what would happen with flexible constraints.
We note that in the judgment of some subtyping relations we have to check if a type is a subtype of "only one" of the union elements. We could say that X <: either U or V
. We introduce a new operator ^
similar to |
, so that we can write this relation as X <: U ^ V
. We also note some subtyping relations where both X and Y <: U
. We write these as X ^ Y <: U
, since it's similar to a union on the left hand. As an example, both X and Y <: either U or V
will in the tables below be written as X ^ Y <: U ^ V
.
Note that there is a subtle difference between this ^
and |
. It is a marker for the distribution action of the oneof modifier, and closely resembles a union. It, however, cannot be looked at as some kind of "xor type operator". "only one" here is a loose interpretation and is talking about the distribution semantics of the oneof
modifier rather than some type level exclusion. If the oneof
modifier interpreted unions as xor operators then the extends oneof
constraint would become ill defined:
type U = { foo: number }
type V = { bar: number }
type Foo<T extends oneof U | V> = // ...
// Would not be allowed, even though it's a valid extension of either one of the union's constituents.
type Bar = Foo<{ foo: number, bar: number }>
For the subtyping relation <:
, typing-wise ^
is completely equivalent to |
. However this is not the case for equals
constraints, as ^
is not a real type operator but distributes the relationship over its operands. To illustrate:
// Subtype
U <: X ^ Y
⇔ U <: X ∨ U <: Y
⇔ U <: X | Y // Standard logical deduction
// Equality
U = X ^ Y
⇔ U = X ∨ U = Y
⇎ U = X | Y // Not valid for equality relation
This is why when using the oneof
modifier for the purpose of allowing only one of a few possible values for a parameter, it should be used together with the equals
modifier in combination with a handcrafted disjoint union (like a discriminated union). This way the only valid substitutions are the real elements of the union, and clients are not allowed to provide extension types that could potentially match more than one of the union's elements.
type Foo<T extends U | V> = // ...
type Bar<W extends X | Y> = Foo<W> // W <: T ⇔ X | Y <: U | V
// == U, V, X and Y are unary types ==
type FooEA1<T extends allof U> = // ...
type FooEO1<T extends oneof U> = // ...
type FooQA1<T equals allof U> = // ...
type FooQO1<T equals oneof U> = // ...
type FooEAN<T extends allof U | V> = // ...
type FooEON<T extends oneof U | V> = // ...
type FooQAN<T equals allof U | V> = // ...
type FooQON<T equals oneof U | V> = // ...
// unary to unary
type BarEA1<W extends allof X> = FooEA1<W> // W <: T ⇔ X <: U
type BarEA1<W extends allof X> = FooEO1<W> // W <: T ⇔ X <: U
type BarEA1<W extends allof X> = FooQA1<W> // ¬(W <: T)
type BarEA1<W extends allof X> = FooQO1<W> // ¬(W <: T)
type BarEO1<W extends oneof X> = FooEA1<W> // W <: T ⇔ X <: U
type BarEO1<W extends oneof X> = FooEO1<W> // W <: T ⇔ X <: U
type BarEO1<W extends oneof X> = FooQA1<W> // ¬(W <: T)
type BarEO1<W extends oneof X> = FooQO1<W> // ¬(W <: T)
type BarQA1<W equals allof X> = FooEA1<W> // W <: T ⇔ X <: U
type BarQA1<W equals allof X> = FooEO1<W> // W <: T ⇔ X <: U
type BarQA1<W equals allof X> = FooQA1<W> // W <: T ⇔ X = U
type BarQA1<W equals allof X> = FooQO1<W> // W <: T ⇔ X = U
type BarQO1<W equals oneof X> = FooEA1<W> // W <: T ⇔ X <: U
type BarQO1<W equals oneof X> = FooEO1<W> // W <: T ⇔ X <: U
type BarQO1<W equals oneof X> = FooQA1<W> // W <: T ⇔ X = U
type BarQO1<W equals oneof X> = FooQO1<W> // W <: T ⇔ X = U
// n-ary to unary
type BarEAN<W extends allof X | Y> = FooEA1<W> // W <: T ⇔ X | Y <: U - never happens because U is unary
type BarEAN<W extends allof X | Y> = FooEO1<W> // ¬(W <: T)
type BarEAN<W extends allof X | Y> = FooQA1<W> // ¬(W <: T)
type BarEAN<W extends allof X | Y> = FooQO1<W> // ¬(W <: T)
type BarEON<W extends oneof X | Y> = FooEA1<W> // W <: T ⇔ X ^ Y <: U
type BarEON<W extends oneof X | Y> = FooEO1<W> // W <: T ⇔ X ^ Y <: U
type BarEON<W extends oneof X | Y> = FooQA1<W> // ¬(W <: T)
type BarEON<W extends oneof X | Y> = FooQO1<W> // ¬(W <: T)
type BarQAN<W equals allof X | Y> = FooEA1<W> // W <: T ⇔ X | Y <: U - never happens because U is unary
type BarQAN<W equals allof X | Y> = FooEO1<W> // ¬(W <: T)
type BarQAN<W equals allof X | Y> = FooQA1<W> // W <: T ⇔ X | Y = U - never happens because U is unary
type BarQAN<W equals allof X | Y> = FooQO1<W> // ¬(W <: T)
type BarQON<W equals oneof X | Y> = FooEA1<W> // W <: T ⇔ X ^ Y <: U
type BarQON<W equals oneof X | Y> = FooEO1<W> // W <: T ⇔ X ^ Y <: U
type BarQON<W equals oneof X | Y> = FooQA1<W> // W <: T ⇔ X ^ Y = U
type BarQON<W equals oneof X | Y> = FooQO1<W> // W <: T ⇔ X ^ Y = U
// unary to= n-ary
type BarEA1<W extends allof X> = FooEAN<W> // W <: T ⇔ X <: U | V
type BarEA1<W extends allof X> = FooEON<W> // W <: T ⇔ X <: U ^ V
type BarEA1<W extends allof X> = FooQAN<W> // ¬(W <: T)
type BarEA1<W extends allof X> = FooQON<W> // ¬(W <: T)
type BarEO1<W extends oneof X> = FooEAN<W> // W <: T ⇔ X <: U | V
type BarEO1<W extends oneof X> = FooEON<W> // W <: T ⇔ X <: U ^ V
type BarEO1<W extends oneof X> = FooQAN<W> // ¬(W <: T)
type BarEO1<W extends oneof X> = FooQON<W> // ¬(W <: T)
type BarQA1<W equals allof X> = FooEAN<W> // W <: T ⇔ X <: U | V
type BarQA1<W equals allof X> = FooEON<W> // W <: T ⇔ X <: U ^ V
type BarQA1<W equals allof X> = FooQAN<W> // W <: T ⇔ X = U | V
type BarQA1<W equals allof X> = FooQON<W> // W <: T ⇔ X = U ^ V
type BarQO1<W equals oneof X> = FooEAN<W> // W <: T ⇔ X <: U | V
type BarQO1<W equals oneof X> = FooEON<W> // W <: T ⇔ X <: U ^ V
type BarQO1<W equals oneof X> = FooQAN<W> // W <: T ⇔ X = U | V
type BarQO1<W equals oneof X> = FooQON<W> // W <: T ⇔ X = U ^ V
// n-ary to n-ary
type BarEAN<W extends allof X | Y> = FooEAN<W> // W <: T ⇔ X | Y <: U | V
type BarEAN<W extends allof X | Y> = FooEON<W> // ¬(W <: T)
type BarEAN<W extends allof X | Y> = FooQAN<W> // ¬(W <: T)
type BarEAN<W extends allof X | Y> = FooQON<W> // ¬(W <: T)
type BarEON<W extends oneof X | Y> = FooEAN<W> // W <: T ⇔ X ^ Y <: U | V
type BarEON<W extends oneof X | Y> = FooEON<W> // W <: T ⇔ X ^ Y <: U ^ V
type BarEON<W extends oneof X | Y> = FooQAN<W> // ¬(W <: T)
type BarEON<W extends oneof X | Y> = FooQON<W> // ¬(W <: T)
type BarQAN<W equals allof X | Y> = FooEAN<W> // W <: T ⇔ X | Y <: U | V
type BarQAN<W equals allof X | Y> = FooEON<W> // ¬(W <: T)
type BarQAN<W equals allof X | Y> = FooQAN<W> // W <: T ⇔ X | Y = U | V
type BarQAN<W equals allof X | Y> = FooQON<W> // ¬(W <: T)
type BarQON<W equals oneof X | Y> = FooEAN<W> // W <: T ⇔ X ^ Y <: U | V
type BarQON<W equals oneof X | Y> = FooEON<W> // W <: T ⇔ X ^ Y <: U ^ V
type BarQON<W equals oneof X | Y> = FooQAN<W> // W <: T ⇔ X ^ Y = U | V
type BarQON<W equals oneof X | Y> = FooQON<W> // W <: T ⇔ X ^ Y = U ^ V
This fully expanded typing table is kind of obtuse so let's try to consolidate it with universal/existential quantification.
// U and X are any type (unary or n-ary)
type FooEA1<T extends allof U> = // ...
type FooEO1<T extends oneof U> = // ...
type FooQA1<T equals allof U> = // ...
type FooQO1<T equals oneof U> = // ...
type BarEAN<W extends allof X> = FooEAN<W> // W <: T ⇔ X <: U
type BarEAN<W extends allof X> = FooEON<W> // W <: T ⇔ |X| = 1 ∧ ∃ V ∈ U . X <: V
type BarEAN<W extends allof X> = FooQAN<W> // ¬(W <: T)
type BarEAN<W extends allof X> = FooQON<W> // ¬(W <: T)
type BarEON<W extends oneof X> = FooEAN<W> // W <: T ⇔ ∀ Y ∈ X . Y <: U
type BarEON<W extends oneof X> = FooEON<W> // W <: T ⇔ ∀ Y ∈ X . ∃ V ∈ U . Y <: V
type BarEON<W extends oneof X> = FooQAN<W> // ¬(W <: T)
type BarEON<W extends oneof X> = FooQON<W> // ¬(W <: T)
type BarQAN<W equals allof X> = FooEAN<W> // W <: T ⇔ X <: U
type BarQAN<W equals allof X> = FooEON<W> // W <: T ⇔ |X| = 1 ∧ ∃ V ∈ U . X <: V
type BarQAN<W equals allof X> = FooQAN<W> // W <: T ⇔ X = U
type BarQAN<W equals allof X> = FooQON<W> // W <: T ⇔ |X| = 1 ∧ ∃ V ∈ U . X = V
type BarQON<W equals oneof X> = FooEAN<W> // W <: T ⇔ ∀ Y ∈ X . Y <: U
type BarQON<W equals oneof X> = FooEON<W> // W <: T ⇔ ∀ Y ∈ X . ∃ V ∈ U . Y <: V
type BarQON<W equals oneof X> = FooQAN<W> // W <: T ⇔ ∀ Y ∈ X . Y = U
type BarQON<W equals oneof X> = FooQON<W> // W <: T ⇔ ∀ Y ∈ X . ∃ V ∈ U . Y = V
We clearly notice the distributive pattern, and also note the interaction between equals
and extends
constraints. A parameter with an equals
constraint can be assigned to a parameter with an extends
constraint, but never the other way around, as the equality cannot be guaranteed.
Another, maybe clearer notation would be to augment the ^
operator and say that if O = A | B | C
then Ô = A ^ B ^ C
. Then we get the following table:
// U and X are any type (unary or n-ary)
type FooEA1<T extends allof U> = // ...
type FooEO1<T extends oneof U> = // ...
type FooQA1<T equals allof U> = // ...
type FooQO1<T equals oneof U> = // ...
type BarEAN<W extends allof O> = FooEAN<W> // W <: T ⇔ O <: U
type BarEAN<W extends allof O> = FooEON<W> // W <: T ⇔ O <: Û ∧ |O| = 1
type BarEAN<W extends allof O> = FooQAN<W> // ¬(W <: T)
type BarEAN<W extends allof O> = FooQON<W> // ¬(W <: T)
type BarEON<W extends oneof O> = FooEAN<W> // W <: T ⇔ Ô <: U
type BarEON<W extends oneof O> = FooEON<W> // W <: T ⇔ Ô <: Û
type BarEON<W extends oneof O> = FooQAN<W> // ¬(W <: T)
type BarEON<W extends oneof O> = FooQON<W> // ¬(W <: T)
type BarQAN<W equals allof O> = FooEAN<W> // W <: T ⇔ O <: U
type BarQAN<W equals allof O> = FooEON<W> // W <: T ⇔ O <: Û ∧ |O| = 1
type BarQAN<W equals allof O> = FooQAN<W> // W <: T ⇔ O = U
type BarQAN<W equals allof O> = FooQON<W> // W <: T ⇔ O = Û ∧ |O| = 1
type BarQON<W equals oneof O> = FooEAN<W> // W <: T ⇔ Ô <: U
type BarQON<W equals oneof O> = FooEON<W> // W <: T ⇔ Ô <: Û
type BarQON<W equals oneof O> = FooQAN<W> // W <: T ⇔ Ô = U
type BarQON<W equals oneof O> = FooQON<W> // W <: T ⇔ Ô = Û
let f: <T extends U | V>(t: T) => void
let g: <W extends X | Y>(t: W) => void
f = g // g is assignable to f if U | V is a subtype of X | Y
let fea: <T extends allof U>(t: T) => void
let feo: <T extends oneof U>(t: T) => void
let fqa: <T equals allof U>(t: T) => void
let fqo: <T equals oneof U>(t: T) => void
let gea: <W extends allof X>(t: W) => void
let geo: <W extends oneof X>(t: W) => void
let gqa: <W equals allof X>(t: W) => void
let gqo: <W equals oneof X>(t: W) => void
// let's call the type on the left hand side R and the type on the right hand side S
fea = gea // S <: R ⇔ U <: X
feo = gea // S <: R ⇔ ∀ V ∈ U . V <: X
fqa = gea // S <: R ⇔ U <: X
fqo = gea // S <: R ⇔ ∀ V ∈ U . V <: X
fea = geo // S <: R ⇔ |U| = 1 ∧ ∃ Y ∈ X . U <: Y
feo = geo // S <: R ⇔ ∀ V ∈ U . ∃ Y ∈ X . V <: X
fqa = geo // S <: R ⇔ |U| = 1 ∧ ∃ Y ∈ X . U <: Y
fqo = geo // S <: R ⇔ ∀ V ∈ U . ∃ Y ∈ X . V <: X
fea = gqa // ¬(S <: R)
feo = gqa // ¬(S <: R)
fqa = gqa // S <: R ⇔ U = X
fqo = gqa // S <: R ⇔ ∀ V ∈ U . V = X
fea = gqo // ¬(S <: R)
feo = gqo // ¬(S <: R)
fqa = gqo // S <: R ⇔ |U| = 1 ∧ ∃ Y ∈ X . U = Y
fqo = gqo // S <: R ⇔ ∀ V ∈ U . ∃ Y ∈ X . V = Y
Or in terse notation:
let fea: <T extends allof U>(t: T) => void
let feo: <T extends oneof U>(t: T) => void
let fqa: <T equals allof U>(t: T) => void
let fqo: <T equals oneof U>(t: T) => void
let gea: <W extends allof O>(t: W) => void
let geo: <W extends oneof O>(t: W) => void
let gqa: <W equals allof O>(t: W) => void
let gqo: <W equals oneof O>(t: W) => void
// let's call the type on the left hand side R and the type on the right hand side S
fea = gea // S <: R ⇔ U <: O
feo = gea // S <: R ⇔ Û <: O
fqa = gea // S <: R ⇔ U <: O
fqo = gea // S <: R ⇔ Û <: O
fea = geo // S <: R ⇔ U <: Ô ∧ |U| = 1
feo = geo // S <: R ⇔ Û <: Ô
fqa = geo // S <: R ⇔ U <: Ô ∧ |U| = 1
fqo = geo // S <: R ⇔ Û <: Ô
fea = gqa // ¬(S <: R)
feo = gqa // ¬(S <: R)
fqa = gqa // S <: R ⇔ U = O
fqo = gqa // S <: R ⇔ Û = O
fea = gqo // ¬(S <: R)
feo = gqo // ¬(S <: R)
fqa = gqo // S <: R ⇔ U = Ô ∧ |U| = 1
fqo = gqo // S <: R ⇔ Û = Ô
oneof
modifier and clearing up its semantics.In the subtyping analysis above we introduced the following example showing that due to open types in TypeScript a type parameter to a extends oneof
function can be valid for multiple elements of the union:
type U = { foo: number }
type V = { bar: number }
type Foo<T extends oneof U | V> = // ...
type Bar = Foo<{ foo: number, bar: number }>
This calls into question the soundness of the oneof
modifier. Unions in Typescript are not guaranteed to be disjoint. What does it mean to have a oneof
constraint with an overlapping union as its base type? Is this even a safe construct? What are the exact semantics of the oneof
modifier? We will analyze these questions in the following sections.
oneof
semanticsWe'll first naively define the semantics of oneof
and then see how well this definition holds up:
Assuming a type parameter T equals oneof U | V
and a type type K<L> = ...
T
can be instantiated with only 1 of the elements of U | V
.K<T>
results in K<U> | K<V>
instead of K<U | V>
.The first rule seems simple. You cannot do for instance
declare function foo<T equals oneof U | V>()
foo<U | V>()
However this definition is naive. U | V
is not guaranteed to be disjoint, so a type of U
could potentially also match V
. Are we just only allowed to supply a unary type? It's ambiguous what should happen here but we'll explore that in the next sections.
Why do we want this anyway? For one this is simply the other side of the coin of the distributivity of oneof
, but it is also necessary to solve our problem statements. To solve the mapping problem, the reason we need this rule is because inside the function we want to be able to assume that operations we are doing on the type proceed as if there were only 1 element of the union passed, while the oneof
modifier automatically expands our type operations into a union while typechecking. Though the second rule is all we need to resolve the types inside the function, without enforcing the first rule this assumption that the second rule relies on would be unsound.
One can also look at it this way: if we simply applied the second rule without the first rule, then inside the function we'd be dealing with these K<U, U> | K<V, V>
types that are completely nonsensical when you compare them to the term-level code that generates them. But the first rule makes it legal to do this transformation. While the second rule makes K<T, T>
resolve to K<U, U> | K<V, V>
instead of K<U | V, U | V>
(which would be the type that does accurately represent the code), the first rule means that in practice when the function is instantiated this never happens. Instead K<T, T>
can only resolve to K<U, U>
or K<V, V>
, which are both unary again. So it's the combination of the 2 rules that incorporates the idea that "this function only operates on one of the union members at a time".
This assumption also has advantages for narrowing, as in the example from the original post (edited to follow our syntax):
function smallest<T equals oneof string | number>(x: T[]): T {
if (x.length == 0) {
throw new Error('empty')
}
if (typeof x[0] == "string") {
// it is either the case that T extends string or that T extends number.
// typeof (anything extending number) is not "string", so we know at this point that
// T extends string only.
return smallestString(x) // legal
}
// at this point, we know that if T extended string, it would have exited the first if.
// therefore, we can safely call
return smallestNumber(x)
}
We identified 4 options:
oneof
constraint through our studies below.oneof
constrained type parameters to only provably disjoint unions (i.e. discriminated unions, type literals and other type parameters constrained by oneof
). This would also require disallowing extends oneof
as it can be used to trivially extend a disjoint union into an overlapping one.oneof
constrained type parameters with anything else than other oneof
type parameters. This would lose us a lot of functionality and is probably not necessary.Let's look at the symbolic type checking within the function, as this is where the second rule comes into play. We'll provide a few examples to show how it is supposed to operate.
First, a simple variable assignment.
const fn<T equals oneof { key: 'bar' } | { key: 'foo' }>(t: T) {
const a: string = t.key
// T['key'] extends string
// => { key: 'bar' }['key'] | { key: 'foo' }['key'] extends string
// => 'bar' | 'foo' extends string
// => true
}
This is trivial. Note however that we are checking U['key'] | V['key']
and not (U | V)['key']
. It ends up giving the same result though, since an access of a union already distributes.
Let's make it a little more interesting:
const fn<T equals oneof U | V>() {
const a: Objects[] = new Array<T>()
// T[] extends Objects[]
// => U[] | V[] extends (U | V)[]
// => true
}
This is also ok, but by casting back to Objects
we have lost the extra information that T
brings, namely that you can assume T
is only instantiated with 1 element of T
.
All of this seems rather trivial. Does this modifier even do something? It absolutely does! Consider this code:
function fn<T equals oneof string | number>(n: T) {
return [n]
}
What would the return type of this function look like? It would look like T[]
of course. But if you were to instantiate this type to it's highest constrained definition you would not get (string | number)[]
, but instead string[] | number[]
.
But this rule's power really shows itself when you use this type parameter multiple times in one instantiation.
type A = { obj: { bar: number }, key: 'bar' }
type B = { obj: { baz: number }, key: 'baz' }
function fn<T equals oneof A | B>(o: T) {
return [o.obj, o.key]
}
Whereas with normal generics the maximum constraint for the return value here would be [{ bar: number } | { baz: number }, 'bar' | 'baz']
, with oneof constraints the return value is instead [{ bar: number }, 'bar'] | [{ baz: number }, 'baz']
, preserving the mapping.
Now moving on to the mapping problem: this is an interesting case because here the type parameter is in a contravariant position. We'll make the type parameters explicit so it's clear what's happening:
type A = { obj: { bar: number }, key: 'bar' }
type B = { obj: { baz: number }, key: 'baz' }
type Objects = A | B
declare const fn: <K extends string, O extends Record<K, unknown>>(o: O, key: K) => void
function fn2<T equals oneof Objects>({ obj, key }: T) {
fn<T['key'], T['obj']>(obj, key)
// => the constraints typecheck using the rules from the subtyping analysis:
// => `equals oneof` to `extends allof`, so rule is `W is assignable to T if X ^ Y is a subtype of U | V`
// => do the test for T = A
// => A['key'] extends string => true
// => A['obj'] extends Record<A['key'], unknown> => true
// => success.
// => do the test for T = B
// => B['key'] extends string => true
// => B['obj'] extends Record<B['key'], unknown> => true
// => success.
}
equals oneof
caseFirst we'll analyze the equals oneof
case, as it reduces the complexity of the problem. We will be looking at the following base case derived from our problem statements:
// The first union element here intentionally overlaps with second one.
type A = { obj: { bar: unknown }, key: 'bar' }
type B = { obj: { bar: unknown }, key: 'bar' }
type C = { obj: { baz: unknown }, key: 'baz' }
type Objects = A | B | C
declare const fn: <K extends string, O extends Record<K, unknown>>(o: O, key: K) => void
function fn2<T equals oneof Objects>(t: T) {
fn(t.obj, t.key)
}
function fn3<T equals oneof Objects>(key: T['key']): T['obj'] {
switch (key) {
case 'bar': return { bar: 3 }
case 'baz': return { baz: 5 }
}
}
Looking at our example above, can we call fn2<A>(...)
? A
would equal both A
and B
. Let's look at some use cases. We'll act as if the types are being fully instantiated even inside the function. If the resulting types would cause a type error without applying the oneof
distribution we have identified a problem.
// unary parameter
fn2<A>({ obj: { bar: 4 }, key: 'bar' })
// A['obj'] resolves to { bar: unknown } and A['key'] resolves to 'bar'
// therefore our accesses have unary types and the call succeeds
//
// So this is fine even though the input type overlaps with another input.
// n-ary parameter
fn2<A | B>({ obj: { bar: 4 }, key: 'bar' }) // typechecks
// (A | B)['obj'] resolves to { bar: unknown } | { bar: unknown } and (A | B)['key'] resolves to 'bar' | 'bar'
// though these are not unary types they behave as if they are because they are equal
//
// So this is also fine even though a union is provided as type argument.
It turns out that for the equals oneof
constraint in practice none of these calls cause any problems, since any overlapping elements are guaranteed to be equal. Let's look at return types.
fn3<A>('bar')
// A['obj'] resolves to { bar: unknown }, { bar: 3 } extends { bar: unknown } so this is fine
fn3<A | B>('bar')
// (A | B)['obj'] resolves to { bar: unknown } | { bar: unknown }, { bar: 3 } extends { bar: unknown } | { bar: unknown } so this is fine
This is not a surprise. A union of 2 identical types is the same as the type itself.
extends oneof
caseNow we'll have a look at the extends oneof
case. Note that using extends oneof
automatically gives a client the power to trivially create overlapping types.
// The first union element here intentionally overlaps with the second one.
type A = { obj: { bar: unknown, boo: unknown }, key: 'bar' }
type B = { obj: { bar: unknown }, key: 'bar' }
type C = { obj: { baz: unknown }, key: 'baz' }
type Objects = A | B | C
declare const fn: <K extends string, O extends Record<K, unknown>>(o: O, key: K) => void
function fn2<T extends oneof Objects>(t: T) {
fn(t.obj, t.key)
}
// fn3 is unsound with `extends oneof` so was elided
// unary parameter
fn2<A>({ obj: { bar: 4, boo: 5 }, key: 'bar' })
// A['obj'] resolves to { bar: unknown, boo: unknown } and A['key'] resolves to 'bar'
// therefore our accesses have unary types and the call succeeds
//
// So this is fine even though the input type overlaps with another input.
// n-ary parameter
fn2<A | B>({ obj: { bar: 4, boo: 5 }, key: 'bar' }) // typechecks
// (A | B)['obj'] resolves to { bar: unknown, boo: unknown } | { bar: unknown } and (A | B)['key'] resolves to 'bar' | 'bar'
// if passed to fn these would typecheck
//
// So this is also fine even though a union is provided as type argument.
It doesn't feel like this is all though. Can we break it? Maybe partially overlapping types?
// The first union element here intentionally overlaps with the second one.
type A = { obj: { bar: unknown, boo: unknown }, key: 'bar' }
type B = { obj: { bar: unknown, foo: unknown }, key: 'bar' }
type C = { obj: { baz: unknown }, key: 'baz' }
// unary parameter
fn2<A>({ obj: { bar: 4, boo: 4 }, key: 'bar' })
// A['obj'] resolves to { bar: unknown, boo: unknown } and A['key'] resolves to 'bar'
// therefore our accesses have unary types and the call succeeds
//
// So this is fine even though the input type overlaps with another input.
// n-ary parameter
fn2<A | B>({ obj: { bar: 4, boo: 4 }, key: 'bar' }) // typechecks
// (A | B)['obj'] resolves to { bar: unknown, boo: unknown } | { bar: unknown, foo: unknown } and (A | B)['key'] resolves to 'bar' | 'bar'
// if passed to fn these would typecheck
//
// So this is also fine even though a union is provided as type argument.
It seems that non-disjoint unions are not necessarily a problem. Every time the type check succeeds because the key
prop depends on another part of the object than the object that changed. The core qualifier of whether a union is safe to pass through the oneof constraint seems to be dependent on the specifics of the code path of the function. This is painful. A simple way to check if a substitution is valid would be to substitute while pretending the constraint is not oneof
, and typecheck the function. But this would be computationally expensive.
Let's look instead at the option of just requiring parameters to be unary.
Yeah, this is probably the way to go for now. But let's elaborate a bit. The only possible problem with this approach as far as I can see at the moment is that maybe someone could do this:
type Foo = string | number
const fn = <T equals oneof Foo | symbol>(t: T) {}
and expect someone to be able to call this function as fn<Foo>()
, which is not possible. But this actually turns out to be a good argument for only allowing unary parameter, as the declaration above just doesn't do what the author thinks it does. This declaration would distribute over string, number and symbol. Not over (string | number) and symbol. This behavior is the core difference between my proposal and the original proposal in the OP, and is absolutely necessary to be able to properly solve the mapping problem.
From the studies above we have learned that it is no problem at all that the constituents of the union overlap. As long as only one constituent of the union is passed, the types check out correctly. So a "unary parameters only" restriction fits perfectly and also maps well onto the semantics of the oneof
keyword.
With that, let's reformalize the semantics.
oneof
semanticsequals oneof
Assuming a type parameter T equals oneof U | V
and a type type K<L, M> = ...
T
can be instantiated only with a unary (non-union) type, and this type must be equal to either U
or V
.K<T, T>
results in K<U, U> | K<V, V>
instead of K<U | V, U | V>
.extends oneof
Assuming a type parameter T extends oneof U | V
and a type type K<L, M> = ...
T
can be instantiated only with a unary (non-union) type, and this type must be a subtype of U | V
.K<T, T>
results in K<U, U> | K<V, V>
instead of K<U | V, U | V>
.Many constructs in TypeScript use some notion of constraints. And some of them can be simplified by making them use the new flexible constraints. Let's look at how these would interplay with flexible constraints.
type FooEA<T extends allof U | V> = T[]
type FooEO<T extends oneof U | V> = T[]
type FooQA<T equals allof U | V> = T[]
type FooQO<T equals oneof U | V> = T[]
type FooEA_<T extends U | V> = T[]
type FooQA_<T equals U | V> = T[]
// Passing a single element of the union just works as normal
FooEA<U> // U[]
FooEO<U> // U[]
FooQA<U> // U[]
FooQA<U> // U[]
// Passing a union works as before as well for allof constraints, but is disallowed for oneof constraint
FooEA<U | V> // (U | V)[]
FooEO<U | V> // type error
FooQA<U | V> // (U | V)[]
FooQA<U | V> // type error
Note that while conceptually for the type checker oneof
constraints distribute over the types they are instantiated in during typechecking (generating U[] | V[]
), while actually working with these constraints as a developer we never see these types, as any substitution that would generate them is disallowed due to the first rule of the oneof
definition.
type A = {
[K in keyof Foo]: K
}
// The constraint in mapped types is always `equals oneof`, as it is for any built in iteration constructs.
infer
statements// Using brackets to isolate the infer statement and avoid distribution from the conditional type
type FooEA<T> = [T] extends [infer S extends allof U | V] ? S[] : never
type FooEO<T> = [T] extends [infer S extends oneof U | V] ? S[] : never
type FooQA<T> = [T] extends [infer S equals allof U | V] ? S[] : never
type FooQO<T> = [T] extends [infer S equals oneof U | V] ? S[] : never
type FooEA_<T> = [T] extends [infer S extends U | V] ? S[] : never
type FooQA_<T> = [T] extends [infer S equals U | V] ? S[] : never
// Passing a single element of the union just works as normal
FooEA<U> // U[]
FooEO<U> // U[]
FooQA<U> // U[]
FooQA<U> // U[]
// Passing a union will fail the match for `oneof` constraints
FooEA<U | V> // (U | V)[]
FooEO<U | V> // never
FooQA<U | V> // (U | V)[]
FooQA<U | V> // never
type FooEA<T> = T extends allof U | V ? T[] : never
type FooEO<T> = T extends oneof U | V ? T[] : never
type FooQA<T> = T equals allof U | V ? T[] : never
type FooQO<T> = T equals oneof U | V ? T[] : never
type FooEA2<T> = [T] extends allof [U | V] ? T[] : never
type FooEO2<T> = [T] extends oneof [U | V] ? T[] : never
type FooEO2b<T> = [T] extends oneof [U] | [V] ? T[] : never
type FooQA2<T> = [T] equals allof [U | V] ? T[] : never
type FooQO2<T> = [T] equals oneof [U | V] ? T[] : never
type FooQO2b<T> = [T] equals oneof [U] | [V] ? T[] : never
type FooEA_<T> = T extends U | V ? T[] : never
type FooQA_<T> = T equals U | V ? T[] : never
type FooEA2_<T> = [T] extends [U | V] ? T[] : never
type FooQA2_<T> = [T] equals [U | V] ? T[] : never
FooEA<U> // U[]
FooEO<U> // U[]
FooQA<U> // U[]
FooQO<U> // U[]
FooEA2<U> // U[]
FooEO2<U> // U[]
FooEO2b<U> // U[]
FooQA2<U> // U[]
FooQO2<U> // U[]
FooQO2b<U> // U[]
FooEA_<U> // U[]
FooQA_<U> // U[]
FooEA2_<U> // U[]
FooQA2_<U> // U[]
FooEA<U | V> // U[] | V[]
FooEO<U | V> // never
FooQA<U | V> // U[] | V[]
FooQO<U | V> // never
FooEA2<U | V> // (U | V)[]
FooEO2<U | V> // (U | V)[]
FooEO2b<U | V> // never
FooQA2<U | V> // (U | V)[]
FooQO2<U | V> // (U | V)[]
FooQO2b<U | V> // never
FooEA_<U | V> // U[] | V[]
FooQA_<U | V> // U[] | V[]
FooEA2_<U | V> // (U | V)[]
FooQA2_<U | V> // (U | V)[]
We also find that it would be possible to reuse the allof / oneof keywords to make the distributive behavior of conditionals more explicit (instead of using the automatic switch based on naked or non-naked type parameters):
type FooEA<T> = allof T extends allof U | V ? T[] : never
type FooEO<T> = oneof T extends allof U | V ? T[] : never
type FooQA<T> = allof T equals allof U | V ? T[] : never
type FooQO<T> = oneof T equals allof U | V ? T[] : never
FooEA<U | V> // (U | V)[]
FooEO<U | V> // U[] | V[]
FooQA<U | V> // (U | V)[]
FooQO<U | V> // never -- note this interesting case. neither U nor V equal U | V. this is new behavior introduced by the equals constraint.
FooQO<U> // U[] -- instead using a unary type as input does succeed.
satisfies
const fooEA = { ... } as const satisfies extends allof U | V
const fooEO = { ... } as const satisfies extends oneof U | V
const fooQA = { ... } as const satisfies equals allof U | V
const fooQO = { ... } as const satisfies equals oneof U | V
const fooEA = { ... } as const satisfies extends U | V
const fooQA = { ... } as const satisfies equals U | V
// This is the only context where the transitivity constraint can be elided for backwards compatibility
const fooEA = { ... } as const satisfies U | V
for ... of
In order to actually tackle the mapping problem we need an entry point that provides us with a oneof
constraint that is intrinsically true / known to be true by the compiler. For the first MVP we will limit this to the builtin for ... of
looping construct.
In theory any iteration produces oneof
items.
const arr = [...] as const
for (const f of foos) {
// Whereas before f would have the type `(typeof arr)[number]`, now it would have the type
// `F equals oneof (typeof arr)[number]`
}
Hi @angryzor, I'm trying to follow you and I have a question about your third post. But I'm not sure that this issue (#27808) is the place. I think this issue is a feature request. And what you are doing is trying to collaborate with one solution for this.
Hi @angryzor, I'm trying to follow you and I have a question about your third post. But I'm not sure that this issue (#27808) is the place. I think this issue is a feature request. And what you are doing is trying to collaborate with one solution for this.
Hi @emilioplatzer , I don't entirely understand your message.
Should I not be posting these analyses here? I have never tried to contribute to TypeScript before, if I am misusing this issue please let me know! Just wanted to contribute by refining the semantics of the oneof
proposal. Though indeed I am only looking at it from the perspective of 1 potential solution.
If instead what you are saying is that you would like to ask me a question not entirely related to this issue, you can find me on the TypeScript Community Discord under the same username and I would be glad to answer any questions you have. If that doesn't work for you, let me know, we can probably find an alternative.
oneof
as a type operatorSometimes you spend days on a complex solution for a problem, something external forces you to take a break and then you get a shower thought that makes you want to go back to the beginning and challenge your initial assumptions. For me this happened today, and the initial assumption that suddenly didn't seem so certain anymore is the assumption that oneof
is a constraint and couldn't possibly be a type operator.
This moment came about as I was stuck in traffic and musing the potential implementation of a function like map
in e.g. lodash and how it would be possible to implement this function in the presence of the equals oneof
constraint. Of course you could implement it like this:
function map<T, U>(f: (t: T) => U, xs: readonly T[]) {
for (const x of xs) {
f(x)
}
}
But what if instead you wanted to do this:
function map<T, U>(f: (t: T) => U, xs: readonly T[]) {
for (let i = 0; i < xs.length; i++) {
f(xs[i])
}
}
At this point xs[i]
is not a oneof
type parameter, and this would not typecheck. So how could you make it typecheck. Currently the only 2 ways to call a function with a oneof
type parameter is either calling it with a unary type (which you can't do here) or calling it with a oneof
type parameter, which we don't have. You could do something pretty dirty, maybe this:
function map<T, U>(f: (t: T) => U, xs: readonly T[]) {
for (let i = 0; i < xs.length; i++) {
(f as any)(xs[i])
}
}
But this is deeply unsatisfactory. My next thought was "if oneof
was a type operator then you could just do this:
function map<T, U>(f: (t: T) => U, xs: readonly T[]) {
for (let i = 0; i < xs.length; i++) {
f(xs[i] as oneof T)
}
}
I had already assumed oneof
could only be a constraint modifier, but this assumption only came from preconceptions and a lot of naysaying in issue threads. However, I spent 3 days now doing research into the semantics of oneof
as a constraint. It would only be fair to do the same for oneof
as a type operator, instead of dismissing it purely out of preconceptions.
So with that, let's look at the semantics of oneof
as a type operator. I will ignore the switch return problem, as to solve this issue we do still need the equals constraint modifier (so the work I have done up till now would in any case not be in vain).
Are TypeScript unions truly unions? They're supposed to be unions, but not really... If the union operator produced a true union then the resulting type would be opaque. But it's not. We can for instance use distributive conditional types to iterate over the elements of a union. If that is the case then that means TS unions are more like "a set of types". That's also why the oneof
construct even makes sense as a concept. If the union operator just produces a set of types, and this underlying structure is not destroyed, then it can also be undone. In other words there must be an inverse of the union operator. In fact this operator is already being used under the hood, as "distributive conditional types with a naked type parameter".
What if this inverse operator can be made explicit, and its name is oneof
. Take a type oneof X
to signify "any of the constituents of the union X". Let's have a look at what kind of laws and behavior would spring from this definition.
Of course we'd have a trivial subtyping relation (I'm reusing the syntax from my oneof
-as-a-constraint comments here):
Ô <: oneof O
I.e. each of the constituents of the union O
would be subtypes of oneof O
. However notably any subsets that are themselves unions wouldn't be, as oneof would completely deconstruct the union. This immediately gives us rule 1 from our constraint-based analysis. All subtypes of oneof O
are unary types.
The following should probably also hold:
oneof O <: O
If you have any constituent of O you can also assign it to a variable of type O.
From that first subtyping relationship I feel like maybe oneof O
is an existential type: ∃ P ∈ O . P
.
When you deal with existential types, your types can have multiple valid instantiations. What kind of instantiations could we have for these types? Given the oneof
operator is a compiler intrinsic with a fixed dimension it should be able to figure out potential instantiations.
What would be potential instantiations of oneof (string | number)
? Well, simply string
and number
. Simple enough. What about (oneof (string | number))[]
? The only 2 options would be string[]
and number[]
. Huh. This sounds familiar. In fact this sounds very familiar! This is exactly the behavior that we expected from rule 2 of our oneof
constraint in previous brainstorms. It might be that we were describing an existential type all along. Let's try to plug this into one of our experiments.
type A = { obj: { bar: number }, key: 'bar' }
type B = { obj: { baz: number }, key: 'baz' }
function fn<T extends oneof A | B>(o: T) {
return [o.obj, o.key]
}
So now we return a type [T['obj'], T['key']]
where T
is a type parameter with base constraint oneof A | B
. If we try to instantiate to the maximum constraint like we did in the constraint-based analysis we get multiple possible instantiations: [{ bar: number }, 'bar']
and [{ baz: number }, 'baz']
. Just like in the constraint-based example.
What about the original mapping problem example?
const foo = [
{ obj: { bar: 5 }, key: 'bar' },
{ obj: { baz: 7 }, key: 'baz' },
] as const
declare function fn<K extends string, O extends Record<K, unknown>>(o: O, key: K): void
for (const f of foo) {
fn(f.obj, f.key)
}
NOTE: The following paragraphs have been proven false, but I'm leaving them here for context.
At first we may think that f
doesn't need to be a type parameter now. But if we try this out so that f
just gets the type oneof (typeof foo)
from the for ... of
construct we bump into the problem from before: K
gets instantiated with oneof (typeof foo)['obj']
and O
gets instantiated with oneof (typeof foo)['key']
, losing the relationship between both arguments. So it seems we still need f
's type to be a type parameter to avoid this from happening.
Every time we try to use this type without the accompanying generic type parameter we lose what makes it useful for us:
type A = { obj: { bar: number }, key: 'bar' }
type B = { obj: { baz: number }, key: 'baz' }
function fn(o: oneof A | B) {
return [o.obj, o.key] // Output type is now [(oneof A | B)['obj'], (oneof A | B)['key']], which will just end up as a cartesian product.
}
So it seems that to solve the mapping problem we really do need generic type parameters, no matter if we are using the constraint approach or introduce oneof
as an existential type.
We can do the cast from that map
implementation now though:
function map<T, U>(f: (t: oneof T) => U, xs: readonly T[]) {
for (let i = 0; i < xs.length; i++) {
f(xs[i] as oneof T)
}
}
In fact, you may not need to. The index could also just return oneof T
. oneof
just becomes a type that says “one of the union’s constituents. you don’t know which, but the module passing you this value may. and in some cases that’s the compiler”.
And the idea that the type distribution comes from the compiler trying to solve an existential type instead of some special constraint is alluring.
oneof
as an existential typeLet's look at some subtyping rules next. I'll be ignoring the equals relation for now since I now consider this a separate issue. I'll use the quantification syntax this time as the existential type can literally be written using that same syntax and it will be very obvious if they are hiding in these typing rules.
The typing table for the constraint approach:
// U and X are any type (unary or n-ary) and not `oneof`
type FooEA<T extends U> = // ...
type FooEO<T extends oneof U> = // ...
type BarEA<W extends X> = FooEA<W> // W <: T ⇔ X <: U
type BarEA<W extends X> = FooEO<W> // W <: T ⇔ |X| = 1 ∧ ∃ V ∈ U . X <: V
type BarEO<W extends oneof X> = FooEA<W> // W <: T ⇔ ∀ Y ∈ X . Y <: U
type BarEO<W extends oneof X> = FooEO<W> // W <: T ⇔ ∀ Y ∈ X . ∃ V ∈ U . Y <: V
The typing table for the existential type approach:
// U and X are any type (unary or n-ary) and not `oneof`
type FooEA<T extends U> = // ...
type FooEO<T extends oneof U> = // ...
type BarEA<W extends X> = FooEA<W> // W <: T ⇔ X <: U
type BarEA<W extends X> = FooEO<W> // W <: T ⇔ X <: U
type BarEO<W extends oneof X> = FooEA<W> // W <: T ⇔ X <: U
type BarEO<W extends oneof X> = FooEO<W> // W <: T ⇔ X <: U
The typing table for the constraint approach:
let fea: <T extends U>(t: T) => void
let feo: <T extends oneof U>(t: T) => void
let gea: <W extends X>(t: W) => void
let geo: <W extends oneof X>(t: W) => void
fea = gea // S <: R ⇔ U <: X
feo = gea // S <: R ⇔ ∀ V ∈ U . V <: X
fea = geo // S <: R ⇔ |U| = 1 ∧ ∃ Y ∈ X . U <: Y
feo = geo // S <: R ⇔ ∀ V ∈ U . ∃ Y ∈ X . V <: Y
The typing table for the existential type approach:
let fea: <T extends U>(t: T) => void
let feo: <T extends oneof U>(t: T) => void
let gea: <W extends X>(t: W) => void
let geo: <W extends oneof X>(t: W) => void
fea = gea // S <: R ⇔ U <: X
feo = gea // S <: R ⇔ U <: X
fea = geo // S <: R ⇔ U <: X
feo = geo // S <: R ⇔ U <: X
Well, what do you know. So much cleaner. The existential type perfectly captures the subtype relations. And you can even see the existential types sitting in those original subtype relations plain as day. (The universal quantifiers are just existential quantifiers on the left hand side of the subtyping relation.)
This seems like an elegant way of implementing the oneof
proposal. The fact that the existential type naturally forms from the typechecking requirements and the desired semantics is very interesting.
It seems like in both approaches I did the exact same thing. After all ∀ Y ∈ X . Y -> U
is logically equivalent to (∃ Y ∈ X . Y) -> U
.
oneof
type operatorWhat is in layman's terms the core of the oneof
type operator? It inverts the distributive action of the type checker over unions. If you have a union A | B
, instead of proving that "an operation works for A and B" it tries to prove that "the operation works for A and the operation works for B". If you write
function foo(x: A | B) {
}
You are saying "this is a function that can process a value that can either be an A
or a B
.
If instead you write:
function foo(x: oneof (A | B)) {
}
You're instead saying "this is a function that can either process an A
or process a B
". The difference is subtle as long as you are just talking about a simple, unnested type. Because it is effectively nonexistent: oneof X = X
. You can even see this when you do the earlier expansion on the function's type:
interface Foo {
(x: oneof (A | B)): void
}
// becomes (intersection because x is in contravariant position)
interface Foo {
(x: A): void
(x: B): void
}
// this is equivalent to
interface Foo {
(x: A | B): void
}
However the difference becomes stark when you use it inside more complex types:
interface Foo {
(x: (oneof (A | B))[]): void
}
interface Foo {
(x: A[]): void
(x: B[]): void
}
interface Foo {
(x: A[] | B[]): void
}
This is not a function that can process (A | B)[]
. It is a function that can process either A[]
or B[]
. Or how about using it on variables:
const a: (oneof (A | B))[] = ['asdfv', 55] // error
The above is not allowed, because the typechecker's goal is "must either satisfy A[]
or satisfy B[]
". And when you use a type parameter with oneof in its constraint you are locking all of its uses together in this process, as you only do this once for its constraint. This is how this implementation solves the mapping problem (note we don't even need the generics anymore):
type A = { obj: { bar: number }, key: 'bar' }
type B = { obj: { baz: number }, key: 'baz' }
type Objects = A | B
declare const fn: <K extends string, O extends Record<K, unknown>>(o: O, key: K) => void
function fn2(f: oneof Objects) {
fn(f.obj, f.key)
// `typeof f.key` is not "the type of accessing `key` on either an A or a B.
// it's "either the type of accessing `key` on an A or the type of accessing `key` on a B",
// and more specifically "the type of accessing `key` on whatever type we have currently instantiated
// for `f`, as the typechecking process becomes iterative.
//
// You'll notice that as a consequence the fate of each type becomes dependent on the oneof
// type it originates from. We're not trying to prove that the function works for an input that
// can be any constituent of `Objects`, we're trying to prove that for every constituent of `Objects`
// the function works.
}
In other words, where (A | B)[]
is equivalent to ∃ X ∈ { A, B } . X[]
or (∀ X ∈ { A, B } . X)[]
, (oneof A | B)[]
is equivalent to ∀ X ∈ { A, B } . X[]
or (∃ X ∈ { A, B } . X)[]
. You can think of it as a variable with a oneof
type having "multiple possible types".
oneof
is viralThe existence of a oneof
type somewhere in a structural type "infects" the entire type, producing a cartesian product type:
type A = {
b: oneof (A | B),
c: oneof (B | C),
}
// is equivalent to
type A = oneof (
| { b: A, c: B }
| { b: B, c: B }
| { b: A, c: C }
| { b: B, c: C }
)
allof
as a way to unpack a oneof
typeI am implementing a complementary allof
type operator that collects all the possible types produced by oneof
and produces the results as a union:
type A = allof {
b: oneof (A | B),
c: oneof (B | C),
}
// is equivalent to
type A =
| { b: A, c: B }
| { b: B, c: B }
| { b: A, c: C }
| { b: B, c: C }
This could also be done like this:
type A = {
b: oneof A | B,
c: oneof B | C,
} extends oneof infer T ? T : never
But providing an operator is more ergonomic and allows us to implement the "explicit conditionals" syntax I mentioned in my first post.
oneof
types cannot be eagerly resolved or dereferenced if they're assigned to a variableThe way oneof
types work makes it impossible to eagerly resolve them if they are assigned to a variable. Consider the following code:
declare const a: oneof ({ foo: 'bar' } | { foo: 'baz' })
const b = a.foo
What is the type of b
? It is impossible to give a definite answer to this as it is dependent on the choice we made while typechecking a
. Its type is simply (typeof a)['foo']
. You could generate a combinatorial maximum bound for this type for the sake of tooltips, but that's it. Its real type cannot be defined. Saying that it's 'bar' | 'baz'
is simply not correct. Saying that it's oneof 'bar' | 'baz'
is also incorrect, that would just start a second iteration and you would typecheck the cartesian product of both types. The fact that you can't disable eager type resolution and the lack of a relation between types of different variables is the core of the mapping problem, and through the mapping problem also hinders a lot of advanced type level programming. A solution for this is one of the core needs mentioned in my first post, and oneof
as a type gives us this for free.
You can see it very loosely like "if a variable is oneof
, then it's the code working with it itself that needs to be distributed".
As you may have noticed, there's a recursive element here that could result in combinatorial explosion. However, this is not necessarily prohibitive. Remember that only values that differ cause issues for typechecking. If you have this code for example:
declare const a: oneof ({ foo: 'bar', xyzzy: boolean } | { foo: 'baz', xyzzy: boolean })
const b = a.xyzzy
The compiler can statically verify that (typeof a)['xyzzy']
is boolean
and can collapse the type of b
to that value. So the type can actually be resolved if the result is the same type for every branch of the type checking process. It's literally the inverse of what the compiler does with property access on normal unions:
map
exampleThis is completely solved. Note that oneof X = X
. This means that either is assignable to the other, so the value can just be passed.
I think I've reached the point where I've done enough research for now. I'm going to try to implement this oneof
type in the typechecker, but it will probably not be as easy as I'm hoping. What started as an attempt to implement a constraint to let people choose only 1 of a union of types turned into the discovery of an entire equally valid complement or "mirror world" to the typechecking algorithm TypeScript uses to handle unions. It would be amazing if this could be added to TypeScript as a language as it would solve a lot of other downstream issues that in essence reduce to the lack of this feature.
In another branch I'll continue work on the constraint modifiers to implement equals
.
I have been writing code for a few evenings and have succeeded in fixing the base case of the mapping problem. Of course much work is still to be done, but here are some sneak peeks:
Mapping problem base case without errors:
An example of an error message if the input is nonsensical:
An example of a homogeneous list defined using oneof
:
From these you can also get an idea of how I went about implementing this. Turns out one of the easiest methods is to implement oneof
as an operator that generates a unique type. So every invocation of oneof
will produce a type that has a different identity. Different oneof types with the same underlying structure will succeed a subtype check, however in the core of the type checker it does differentiate between them when encountering them during type checks:
Every type remembers a cached set of all the "free" oneof types that are below it in the type tree (they can be "bound" by allof
). When the type checker tries to test a subtype (or other) relation, it checks this cached set on both types, generates a cartesian product of all possible instantiations of all the oneofs and does the typecheck for each of them, trying to prove that each instantiation on the subtype side matches at least one instantiation on the supertype side.
It's a pretty simple algorithm currently, and it probably needs some heavy caching and other optimization, but for now I'm first going to make sure that all types are properly handled (e.g. still have to make it work through generic type parameters).
I am still working on this intensively. Everything is going well. I wasted some time trying to make a naive MVP implementation work that tried to crawl the types for oneof
s first before typechecking. This didn't work out at all because this process forced resolution of all the types it encountered and made numerous tests in the TypeScript test suite fail, so instead I had to just do it the right way right away.
I have now built a nice Iterable
abstraction that allows me to iterate over possible instantiations of all oneof
s in an expression using simple for
loops that can be reused all over the code base. It implements a much cleaner algorithm that "discovers" new oneof
s as the client code encounters them. It uses the first constituent as the value it represents during the first iteration, and then registers its other options to be tried in later iterations. This way only types that are actually visited by client code are actually resolved. Using this method I was able to add working typechecking for oneof
types and the whole TypeScript test suite still succeeds! Woohoo!
The presence of allof
is what makes this algorithm more complex. Take the following complex oneof/allof code which currently compiles correctly in my implementation (Note: This type definition may look frightening, but this type is intentionally contrived to test the edge cases of what can technically be done with these operators. In real world code you wouldn't encounter something like this often.):
type Bar<X extends oneof (string | number) = oneof (string | number)> = {
readonly foo: X
readonly zoo: readonly (allof {
readonly goo: X
readonly moo: oneof ('fer' | 'ber')
})[]
}
declare function a(x: Bar)
// This is correct code. The `goo`s and `foo`s have the same type.
// The `moo` is closed over by allof, turning the A type into
// `{ foo: X, zoo: ({ goo: X, moo: 'fer' } | { goo: X, moo: 'ber' })[] }`,
// allowing the `moo` type to vary within the array.
// Note that if we didn't have that allof the whole `A` type would instead be equivalent to
// `oneof ({ foo: X, zoo: { goo: X, moo: 'fer' }[] } | { foo: X, zoo: { goo: X, moo: 'ber' }[] })`,
// which is completely different.
a({ foo: 3, zoo: [{ goo: 3, moo: 'fer' }, { goo: 5, moo: 'ber' }] } as const)
// This is invalid code. `number` is not a valid type for `moo`.
a({ foo: 3, zoo: [{ goo: 3, moo: 'fer' }, { goo: 5, moo: 3 }] } as const)
// This is invalid code. The first `goo` is not the same type as `foo`.
a({ foo: 3, zoo: [{ goo: 'wefvwe', moo: 'fer' }, { goo: 5, moo: 'ber' }] } as const)
// This is invalid code. Even though both `goo`s have the same type, it is not equal to `foo`.
a({ foo: 3, zoo: [{ goo: 'wefvwe', moo: 'fer' }, { goo: 'ewfwe', moo: 'ber' }] } as const)
What this code is built to test is the fact that with oneof
the meaning of a type can change depending on the type it is used in. When looking at this subpart of the type separately (let's drop the readonly
operators, they're just clutter I had to add for those tests to work with the const
parameter):
allof {
goo: X
moo: oneof ('fer' | 'ber')
}
Let's give it a name:
type Zoo<X extends oneof (string | number)> = allof {
goo: X
moo: oneof ('fer' | 'ber')
}
What would this type simplify to if we instantiated it?
type Zoo1 = Zoo<oneof (string | number)>
// =>
type Zoo1 = allof {
goo: oneof (string | number)
moo: oneof ('fer' | 'ber')
}
// =>
type Zoo1 =
| { goo: string, moo: 'fer' }
| { goo: string, moo: 'ber' }
| { goo: number, moo: 'fer' }
| { goo: number, moo: 'ber' }
Ok, simple enough. What if we pass it a subtype?
type Zoo2 = Zoo<string>
// =>
type Zoo2 = allof {
goo: string
moo: oneof ('fer' | 'ber')
}
// =>
type Zoo2 =
| { goo: string, moo: 'fer' }
| { goo: string, moo: 'ber' }
All working as expected. But remember: with oneof
types present the type can have different behavior depending on where it is used! Let's reintroduce it into that original type:
type Bar<X extends oneof (string | number) = oneof (string | number)> = {
foo: X
zoo: Zoo<X>[]
}
And let's look at the expansion of the default instantiation, the oneof
itself. In these instantiations, remember that the 2 oneof
s in Bar1 are the same oneof
. Every oneof
type has its own identity during iteration over the possible values over them, and will have the same value.
type Bar1 = Bar<oneof (string | number)>
// =>
type Bar1 = allof {
foo: oneof (string | number)
zoo: Zoo<oneof (string | number)>[]
}
// =>
type Bar1 = allof {
foo: oneof (string | number)
zoo: allof {
goo: oneof (string | number)
moo: oneof ('fer' | 'ber')
}[]
}
// =>
type Bar1 =
| { foo: string, zoo: ({ goo: string, moo: 'fer' } | { goo: string, moo: 'ber' })[] }
| { foo: number, zoo: ({ goo: number, moo: 'fer' } | { goo: number, moo: 'ber' })[] }
See that? Even though Zoo
was passed a oneof (string | number)
, while in Zoo1
we got a union with 4 constituents as a result of the allof
, in this case we only have 2! It has to work like this to be sound. After all, you could deconstruct with more steps, like this:
type Bar2 = Bar<oneof (string | number)>
// =>
type Bar2 = allof {
foo: oneof (string | number)
zoo: Zoo<oneof (string | number)>[]
}
// =>
type Bar2 =
| { foo: string, zoo: Zoo<string>[] }
| { foo: number, zoo: Zoo<number>[] }
}
// =>
type Bar1 =
| { foo: string, zoo: ({ goo: string, moo: 'fer' } | { goo: string, moo: 'ber' })[] }
| { foo: number, zoo: ({ goo: number, moo: 'fer' } | { goo: number, moo: 'ber' })[] }
And now you can see that Zoo is actually called 2 times with a single subtype.
In the discovery-on-the-go based algorithm I developed this behavior makes the algorithm more complex, because every allof
generates a separate sub-iteration. The typechecker could choose to typecheck the zoo
property first, encounter the oneof
there, generate 4 options for the Zoo
type and only later realize that the goo
actually would have been bound by the upper scope.
To handle this, the algorithm uses an error correction technique. Whenever a oneof
is encountered in a higher scope that was already found in a deeper iteration, the result of the current iteration is considered "tainted", discarded, and the current iteration is re-run with an explicit instantation for that oneof
. This is all built into the Iterable
abstraction. The client code can supply a rollback function to be run when a conflict was found.
Currently I have the following things working:
oneof
has some unique challenges that aren't well covered by the verbalization options we have now. It's hard to make it clear that 2 types may have the same declaration but different identity. In value code and classes there are symbol chains: typeof d.e.g
or MyClass.MyType
, and types can have aliases to which you can refer, but you cannot generate verbalizations that refer to deeply nested members of anonymous types with aliases attached based on the alias, like MyAlias['a']['b']
. I plan on adding that, since any time I fall back to just the oneof (A | B)
output it is inherently ambiguous for developers.Currently doing:
Iterable
abstraction.If you are interested you can follow my progress in this branch: https://github.com/angryzor/TypeScript/tree/feature/oneof
I think I have been conflating my oneof
operator and existentially quantified types over the constituents. I had already suspected something wasn't quite right because every oneof
always had to have their children wrapped in unions.
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 oneof
operator it is possible to group oneof
s inside unions, but not the other way around:
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 oneof
operator because that's what worked for me for the mapping problem, but I don't think this is ideal or the most flexible. The type you actually want here for Bar
but can't express is (A | B) ^ (C | D)
(borrowing the caret operator from above), and it's a different type than A ^ B ^ C ^ D
:
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 ^
operator are not that "a value is not allowed to match 2 of the constituents", it just signifies existential quantification over its constituents, where unions signify universal quantification over their constituents.
What are oneof
and allof
then? Well, they are still very useful, and we need them for the mapping problem. They just convert between ^
(existential) types and |
(union) types. Simple.
So now I have a new task on my list, after I finish implementing inference:
ExistentialType
that takes over most of the oneof
implementation.oneof
into just a type operator with little extra logic.Currently the union/intersection types in the compiler look like this:
Type
└ UnionOrIntersectionType
├ IntersectionType
└ UnionType
I plan to change this to this:
Type
└ SetOperationType
├ IntersectionType
└ QuantificationType
├ UnionType
└ ExistentialType
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.
A 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.
The core problem we're trying to solve with the oneof
implementation is this: considering the following code, what is the type that should be inferred for T
?
string[] | number[] extends (infer T)[]
Currently the typescript compiler will infer string | number
. This is correct, but only because we are using the extends
constraint. It is not the most specific type that can be inferred. In other words, were we to use the as-of-yet-nonexistent constraint type equals
then this inference would not be correct:
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 string[] | number[]
and (string | number)[]
here. string[] | number[]
is either an array of strings or an array of numbers, (string | number)[]
is an array where every item can be either a string or a number. The uneven subtyping relation between the two here is correct. If I have an array that only contains strings or an array that only contains numbers, then I can also advertise it as an array that can hold both strings and numbers. (At least, I could do that if it were immutable and that is where this becomes a bit unsound for mutable arrays but let's ignore that for the moment as it is out of scope of the oneof
solution.) We can't do the inverse. Presenting an array that can hold both as one that can only hold either one or the other isn't safe.
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
{ foo: string } | { foo: number }
here is either an object that you can only get a string out of by accessing foo
, or an object that you can only get a number out of by accessing foo
. Which of the 2 you are holding you don't know, and you'll first have to narrow the type somehow. { foo: string | number }
is the type of an object that you can access a property foo
on, and that property can hold either a string
or a number
.
So when we are trying to solve the oneof
problem, we are trying to define a type that directly embodies this peculiar relationship in the distributed versions of these types.
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. { foo: string } & { foo: number }
for example is the type of an object that you can access foo
and get a string back and an object that you can access foo
on and get a number back. { foo: string & number }
is the type of an object that you can access foo
on and get a value back that is both a string and a number. Note the subtyping relation is flipped in this case.
It may not immediately be apparent why all of this is related to the oneof
type as we see it in function parameters. However it is directly related. The way we currently define functions where we would want to use the oneof
type (removing the type parameters for a moment as they are not significant) is like this:
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 MyFn1
is actually a subtype of MyFn2
:
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. x
is just in contravariant position, so we're looking for the union version of this oneof
type while dealing with an intersection, but the core question is exactly the same: in MyFn2
, what would be the type of x
? The answer is the oneof type we are trying to define.
(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?
@angryzor
This port concern TypeScript behavior and are not at all a criticism of you or your ideas.
As you said, the typescript compiler will infer string | number
for this type declaration case:
type A = string[] | number[] extends (infer T)[] ? T[] : never;
// ?^ (string | number)[]
But if we write it as a type function,
type Id<In> =In extends (infer Out)[] ? Out[] : never;
type X1 = Id<string[]|number[]>;
// ?^ string[] | number[]
it does not lose precision. For the distribution over the union functionality to work, the function form is required.
Another issue: there is some strange TypeScript behavior with the &
calculations:
type SN1 = string[] & number[];
// ^? string[] & number[]
type T1 = SN1[0];
// ?^ never
type A = string[] & number[] extends (string & number)[] ? true : false
// ^? false
type A1 = never[] extends (string & number)[] ? true : false
// ^? true
type B = (string & number)[] extends string[] & number[] ? true : false
// ^? true
type B1 = (string & number)[] extends never[] ? true : false
// ^? true
TypeScript seems to delay the calculation of &
at times - as it does here.
Personally I think it would be better to immediately see
type SN1 = string[] & number[];
// ^? never[]
A
and A1
should be the same, right?B
and B1
should be the same, right?A1
and B1
together imply that never[]
and (string & number)[]
both extend each other, which I believe should mean they are equal. However they are not equal. never[] extends (string & number)[]
should be true (A1
), according to set theory.(string & number)[] extends never[]
should be false (B1
), according to set theory.TypeScript handling of types at sets is not really mathematically precise. At least, there are a few corner cases that I think TypeScript does not properly handle (as of V5.3.3).
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 T
that can be T1
or T2
but not T1|T2
. That is specialy bad for numeric types.
See my example in my previous comment: https://github.com/microsoft/TypeScript/issues/27808#issuecomment-657596002
@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:
return
statements therein. Item 3 is already working, item 2 is not.
Notice that @emilioplatzer post says
Inside that function you cannot have a generic type T that can be T1 or T2 but not T1|T2.
That is talking about item 3.
The OP #27808 title implies that a new form for specifying correlations (extends oneof
) is the main feature - that feature belongs to item 1. However, the OP long content also talks about item 3.
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)
type 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
>
>;
import { 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)
>>
const 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];
}
// 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)
}
const 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];
}
const 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:
export type TupleToUnion<T extends readonly unknown[]> = T[number];
export type UnionToIntersection<U> =
(U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never;
export type GenericFunction<
Generics extends any[] = [],
Args extends any[] = [],
Return = any
> = <G extends Generics, A extends Args>(...args: A) => Return;
export type ConvertTupleOfPossibleOptionsToOverloadsUnion<
TupleOfPossibleOptions extends readonly any[],
Render extends GenericFunction<any, any, any>
> = TupleOfPossibleOptions extends [infer OneOfPossibleOptions, ...infer RestOfPossibleOptions]
? | Render
| ConvertTupleOfPossibleOptionsToOverloadsUnion<RestOfPossibleOptions, Render>
: never;
export type $overload<
TFunc extends GenericFunction<any, any, any>,
TAgainst extends readonly any[]
> = UnionToIntersection<
ConvertTupleOfPossibleOptionsToOverloadsUnion<
TAgainst,
TFunc
>
>
// Example usage:
declare type PossibleOptions = ['a', 'b', 'c'];
type RenderFindAll<OneOfPossibleOptions> = <const T extends OneOfPossibleOptions>(x: T) => T;
declare const method: $overload<RenderFindAll<TupleToUnion<PossibleOptions>>, PossibleOptions>;
const r = method('a'); // r is 'a'
@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)