microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.95k stars 12.47k forks source link

Feature Request: "extends oneof" generic constraint; allows for narrowing type parameters #27808

Open Nathan-Fenner opened 6 years ago

Nathan-Fenner commented 6 years ago

Search Terms

Suggestion

Add a new kind of generic type bound, similar to T extends C but of the form T 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 of T extends A, T extends B, T extends C holds. So, for example, in a function

function smallest<T extends oneof(string, number)>(x: T[]): T {
    if (x.length == 0) {
        throw new Error('empty');
    }
    return x.slice(0).sort()[0];
}

Just like today, these would be legal:

smallest<number>([1, 2, 3);        // legal
smallest<string>(["a", "b", "c"]); // legal

smallest([1, 2, 3]);               // legal
smallest(["a", "b", "c"]);         // legal

But (unlike using extends) the following would be illegal:

smallest<string | number>(["a", "b", "c"]); // illegal
// string|number does not extend string
// string|number does not extend number
// Therefore, string|number is not "in" string|number, so the call fails (at compile time).

// Similarly, these are illegal:
smallest<string | number>([1, 2, 3]);       // illegal
smallest([1, "a", 3]);                      // illegal

Use Cases / Examples

What this would open up is the ability to narrow generic parameters by putting type guards on values inside functions:

function smallestString(xs: string[]): string {
    ... // e.g. a natural-sort smallest string function
}
function smallestNumber(x: number[]): number {
    ... // e.g. a sort that compares numbers correctly instead of lexicographically
}

function smallest<T extends oneof(string, number)>(x: T[]): T {
    if (x.length == 0) {
        throw new Error('empty');
    }
    const first = x[0]; // first has type "T"
    if (typeof first == "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);
}

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 about T; 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 like T extends_oneof(A, B, C); the oneof and the extends 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)

emilioplatzer commented 2 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).

emilioplatzer commented 2 years ago

For still thinking.

Here is a 3 check times ugly solution. playground

It checks at edigint time, with minimal // @ts-ignores, 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.)

DerZade commented 2 years ago

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

craigphicks commented 2 years ago

@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

typescript playground

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

playground

DerZade commented 2 years ago

@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

typescript playground

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

playground

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.

craigphicks commented 2 years ago

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.)

craigphicks commented 2 years ago

@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.

DerZade commented 2 years ago

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.

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 }) // ❌
craigphicks commented 2 years ago

@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.

craigphicks commented 2 years ago

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.

whzx5byb commented 2 years 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.

@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
emilioplatzer commented 2 years ago

@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).

whzx5byb commented 2 years ago

@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. .

emilioplatzer commented 2 years ago

@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".

Try here or here

k8w commented 2 years ago

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') {

    }
  }
}
Diggsey commented 1 year ago

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?

angryzor commented 1 year ago

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 problems I'm trying to solve

The mapping or correspondence problem

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.

The switch return problem

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.

Analysis

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.

The solution I am exploring

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.

Possible modifiers

In terms of modifiers I am currently trying out 2 different "dimensions", which each have defaults:

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`)

How this helps to solve the problems above.

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 {

Additional advantages

Aside from solving these problems, the transitivity dimension could also have additional advantages, for instance:

Transitivity for object types

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.

Predictable behavior for conditional types

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

My current progress

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.

angryzor commented 1 year ago

Subtyping behavior with flexible constraints

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.

Single variable covariant direct substitutions

Legacy constraints:

type Foo<T extends U | V> = // ...

type Bar<W extends X | Y> = Foo<W> // W <: T ⇔ X | Y <: U | V

Flexible constraints:

// == 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 ⇔ Ô = Û

Parameters in contravariant positions on polymorphic functions

Legacy constraints:

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

Flexible constraints:

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 ⇔ Û = Ô
angryzor commented 1 year ago

Analyzing the soundness of the 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.

Naively defining the oneof semantics

We'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> = ...

First rule

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:

Second rule

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.
}

Checking overlapping unions - analyzing the equals oneof case

First 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.

Checking overlapping unions - analyzing the extends oneof case

Now 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.

Unary parameter option

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.

Defining the true oneof semantics

equals oneof

Assuming a type parameter T equals oneof U | V and a type type K<L, M> = ...

extends oneof

Assuming a type parameter T extends oneof U | V and a type type K<L, M> = ...

angryzor commented 1 year ago

Flexible constraints in derived constructs

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 aliases

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.

Mapped types

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

Conditionals

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]`
}
emilioplatzer commented 1 year ago

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.

angryzor commented 1 year ago

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.

angryzor commented 1 year ago

oneof as a type operator

Sometimes 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).

TypeScript unions and their inverse operation

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.

Existential type?

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.

angryzor commented 1 year ago

Subtyping table with oneof as an existential type

Let'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.

Covariant subtyping

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

Contravariant subtyping

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

Conclusion

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.

angryzor commented 1 year ago

Bringing it together

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.

The semantics of the oneof type operator

What 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 viral

The 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 type

I 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 variable

The 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:

The map example

This is completely solved. Note that oneof X = X. This means that either is assignable to the other, so the value can just be passed.

Conclusion

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.

angryzor commented 1 year ago

Small update

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: correspondence

An example of an error message if the input is nonsensical: wrongprop

An example of a homogeneous list defined using oneof: homogeneous-list

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).

angryzor commented 1 year ago

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 oneofs 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.

The core algorithm

I have now built a nice Iterable abstraction that allows me to iterate over possible instantiations of all oneofs 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 oneofs 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 oneofs 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.

What is currently working

Currently I have the following things working:

Currently doing:

If you are interested you can follow my progress in this branch: https://github.com/angryzor/TypeScript/tree/feature/oneof

angryzor commented 1 year ago

A first class existential type

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 oneofs 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:

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.

angryzor commented 1 year ago

What to do here

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.

Getting up to speed

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.

What is the issue that has me blocked?

(TBC...)

laughinghan commented 11 months ago

@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?

craigphicks commented 11 months ago

@angryzor

This port concern TypeScript behavior and are not at all a criticism of you or your ideas.

1

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.

2

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[] 

Conclusion

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).

emilioplatzer commented 11 months ago

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

laughinghan commented 11 months ago

@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.

craigphicks commented 10 months ago

@laughinghan @emilioplatzer @Nathan-Fenner

There are really 3 separate items that are related:

  1. Specifying the call parameter and return type correlations.
  2. Using those correlations for flow analysis inside the function implementation and to validate the return statements therein.
  3. Using those correlations to validate calls to the function.

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.

nikelborm commented 1 week ago

ts playground

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)

Helpers

Definition

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
  >
>;

Tests and usage

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)
>>

Example 1

Definition

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];
}

Tests and usage

// Maria likes apples more, so we increase it's probability, and she also
// hates oranges, so we remove them
const getRandomFruitForMaria =
  () => getRandomElementFromEitherArrayOfFruitsOrColors(['apple', 'apple', 'apple', 'banana']);

assert<Equals<typeof getRandomFruitForMaria, () => 'apple' | 'banana'>>;

// John has no preference for colors
const getRandomColorForJohn = () => getRandomElementFromEitherArrayOfFruitsOrColors(colors);

assert<Equals<typeof getRandomColorForJohn, () => 'red' | 'green' | 'blue'>>;

try {
  // @ts-expect-error Expected 1 arguments, but got 0.ts(2554)
  getRandomElementFromEitherArrayOfFruitsOrColors()
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}

try {
  // @ts-expect-error No overload matches this call.
  //   Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
  //   Type '"asd"' is not assignable to type '"apple" | "orange" | "banana"'.
  // Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
  //   Type '"asd"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
  getRandomElementFromEitherArrayOfFruitsOrColors(['asd'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}

// should not throw errors
getRandomElementFromEitherArrayOfFruitsOrColors(['apple'])

// should not throw errors
getRandomElementFromEitherArrayOfFruitsOrColors(['red'])

try {
  // @ts-expect-error No overload matches this call.
  //   Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
  //   Type '"red"' is not assignable to type '"apple" | "orange" | "banana"'.
  // Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
  //   Type '"apple"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
  getRandomElementFromEitherArrayOfFruitsOrColors(['apple', 'red'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}

try {
  // @ts-expect-error No overload matches this call.
  //   Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
  //   Type '"red"' is not assignable to type '"apple" | "orange" | "banana"'.
  // Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
  //   Type '"apple"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
  getRandomElementFromEitherArrayOfFruitsOrColors(['red', 'apple'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}

Example 2 (to show applicability to the OP's example)

Definition

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];
  }

Tests and usage

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)
}
TheRealMkadmi commented 1 week ago

@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'
lgarron commented 1 week ago

@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? 😄