Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request] Non-Union Generic Type Params #32909

Open
5 tasks done
AnyhowStep opened this issue Aug 15, 2019 · 14 comments
Open
5 tasks done

[Feature Request] Non-Union Generic Type Params #32909

AnyhowStep opened this issue Aug 15, 2019 · 14 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Aug 15, 2019

Search Terms

  • non-union
  • generic
  • type param
  • one of

Suggestion

A way to annotate that a generic type param will not accept union types.
I don't have the syntax or keyword in mind, but I'll just go ahead and use nonUnion as a kind of type param modifier,

type NonUnionX<nonUnion T> = (
  /*Implementation*/
);

Use Cases

What do you want to use this for?

I work with pretty complex types (in my opinion); and a lot of them.

When working on a complex type, I tend to break the problem down into smaller subproblems.

The base case is usually assuming that all type params are non-union.
After that, I build on top of the base case and implement types that support union type params.


Here is an example type that is a base case,
PrimaryKey_NonUnion<TableT>


And here is an example of a type building upon the base case,
PrimaryKey_Output<TableT>

It distributes TableT and uses PrimaryKey_NonUnion. The result is a union if TableT is a union.


And here is an example of another type building upon the base case,
PrimaryKey_Input<TableT>

It distributes TableT and uses PrimaryKey_NonUnion.
Then, it uses UnionToIntersection<> to combine the results into one type.


That experimental repository of mine is filled with instances of generic types that support union types and those that do not.

There are times where you really do not want to pass a union type to a generic type param because it'll result in bugs that may not be noticed till later.

What shortcomings exist with current approaches?

One approach is to just write a comment that says,

/**
 * + Assumes `T` is not a union
 * + Assumes `U` may be a union
 * + Assumes `V` is not a union
 */

This gets very error-prone when you start having hundreds of types.


Another approach is to give your types names that are descriptive,

type SomeOperation_NonUnionT_UnionU_NonUnionV<
  T,
  U,
  V
> = (
  //Implementation
);

This is still error-prone; you may still use it incorrectly.
Even if the name of the type says NonUnionT, you may still pass a union type to T.

Examples

type NonUnionX<nonUnion T> = (
  /*Implementation*/
);
//OK
type nonUnionX1 = NonUnionX<string>;
//Error, Type `NonUnionX` expects non-union for type parameter `0`
//  `string|number` is a union type
type nonUnionX2 = NonUnionX<string|number>;
//                          ~~~~~~~~~~~~~

type UnionX<T> = (
  T extends any ?
  //OK! `T` has been distributed
  NonUnionX<T> : 
  never
);
//OK
type unionX1 = UnionX<string>;
//OK
type unionX2 = UnionX<string|number>;

type Blah<T> = (
  //Error, Type `NonUnionX` expects non-union for type parameter `0`
  //  `T` may be a union type
  NonUnionX<T>
  //        ~
);

A non-approach is to expose only types that handle union type params and to leave non-union implementations unexported.

This is not a useful approach because it means building new types in a different file using these non-union implementations becomes impossible. Since they're unexported.

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

Similar issues

#24085
#27808

@jack-williams
Copy link
Collaborator

Can it not be done using a helper type?

type Check<T,U> = T extends unknown ? ExtractN<U,T> : never;
type ExtractN<T,U> = [T] extends [U] ? U : never;
type NonU<T> = Check<T,T>;

type SomeAlias<T extends NonU<T>> = T;

type A = SomeAlias<never>;
type B = SomeAlias<string>;
type C = SomeAlias<string|number>;
type D = SomeAlias<1 | 2 | 3>;

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Aug 15, 2019

Uhhh... Wtf?

I'm surprised that doesn't give a circular constraint error or something.
I just did some quick tests and it looks like it works,

type Check<T,U> = T extends unknown ? ExtractN<U,T> : never;
type ExtractN<T,U> = [T] extends [U] ? U : never;
type NonU<T> = Check<T,T>;

type NonUnionX<T extends NonU<T> & (string|number)> = (
  /*Implementation*/
  T extends string ?
  "str" :
  T extends number ?
  "num" :
  never
);
//OK
type nonUnionX1 = NonUnionX<string>;
//Error, Type `NonUnionX` expects non-union for type parameter `0`
//  `string|number` is a union type
type nonUnionX2 = NonUnionX<string|number>;
//                          ~~~~~~~~~~~~~

type UnionX<T extends string|number> = (
  T extends string|number ?
  NonUnionX<T> : 
  never
);
//OK
type unionX1 = UnionX<string>;
//OK
type unionX2 = UnionX<string|number>;

type Blah<T extends string|number> = (
  //Error, Type `NonUnionX` expects non-union for type parameter `0`
  //  `T` may be a union type
  NonUnionX<T>
  //        ~
);

type testInvalidParam = NonUnionX<boolean>
//                                ~~~~~~~

Playground

Too bad I can't customize the error message, though. Being told a type isn't assignable to never can be confusing =x

I'll play with this some more when I have the time and see if it messes with type inference for anything. If it doesn't mess with type inference, then you've basically solved my problem.


Even this seems to work,

type Passthrough<T extends NonU<T> & (string|number)> = (
  NonUnionX<T>
);

This is so awesome T^T

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Aug 16, 2019

Hmm..

I finally got the time to play with this,

type Check<T,U> = T extends unknown ? ExtractN<U,T> : never;
type ExtractN<T,U> = [T] extends [U] ? U : never;
type NonU<T> = Check<T,T>;

function foo<T extends NonU<T> & { x : string }> (t : T) {
  //Expected: OK!
  //Actual  : Property 'x' does not exist on type 'T'.
  t.x
}
function foo2<T extends { x : string }> (t : T) {
  //Expected: OK!
  //Actual  : OK!
  t.x
}

Playground

It seems like it complicates implementations quite a bit.
I guess I'll have to use this... Sparingly.


This seems to be okay,

function foo3<T extends NonU<T> & { x : string }> (t : T & { x : string }) {
  //Expected: OK!
  //Actual  : OK!
  t.x
}

But duplicating the constraint is... Eh


I guess I can still use this for helper types that do not have function implementations. So, still helpful!

@AnyhowStep
Copy link
Contributor Author

Seems like it doesn't work for object types, either,

type Check<T,U> = T extends unknown ? ExtractN<U,T> : never;
type ExtractN<T,U> = [T] extends [U] ? U : never;
type NonU<T> = Check<T,T>;

type Obj = {
  [k:string]:string
};
type NonUnionX<T extends NonU<T> & Obj> = (
  /*Implementation*/
  keyof Obj
);
type Blah<T extends Obj> = (
  //Expected: Error
  //Actual  : OK
  //We have not distributed `T` here, it should be treated as potentially
  //being a union type
  NonUnionX<T>
);

Playground

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Aug 16, 2019

I've been playing with this but I can't get it to work =/
Circular constraints hurt my brain.

export type UnionToIntersection<U> = (
    (
        U extends any ? (k: U) => void : never
    ) extends (
        (k: infer I) => void
    ) ? I : never
);
type NonU<T> = [T] extends [UnionToIntersection<T>] ?
  //Defer check to avoid circular constraint error, I think.
  //Can't seem to just use "regular" T
  //T :
  (T extends any ? T : never) :
  never;

type Constraint = {x:string};

type NonUnionX<T extends NonU<T> & Constraint> = (
  /*Implementation*/
  T["x"] extends "hi" ?
  "hello" :
  T["x"] extends "bye" ?
  "goodbye" :
  "???"
);
//OK
type nonUnionX1 = NonUnionX<{x:"hi"}>;
//Error, Type `NonUnionX` expects non-union for type parameter `0`
//  `string|number` is a union type
type nonUnionX2 = NonUnionX<{x:"hi"}|{x:"bye"}>;
//                          ~~~~~~~~~~~~~

type UnionX<T extends Constraint> = (
  T extends Constraint ?
  //OK
  NonUnionX<T> : //<-- Expected OK, Actual Error
  never
);
//OK
type unionX1 = UnionX<{x:"hi"}>;
//OK
type unionX2 = UnionX<{x:"hi"}|{x:"bye"}>;

type Blah<T extends Constraint> = (
  //Error, Type `NonUnionX` expects non-union for type parameter `0`
  //  `T` may be a union type
  NonUnionX<T>
  //        ~
);

type testInvalidParam = NonUnionX<boolean>
//                                ~~~~~~~

type Passthrough<T extends NonU<T> & Constraint> = (
  //OK
  NonUnionX<T>
);

Playground

@jack-williams Teach me, master


Particularly, this part isn't working,

type UnionX<T extends Constraint> = (
  T extends Constraint ?
  //OK
  NonUnionX<T> : //<-- Expected OK, Actual Error
  never
);

After looking at your implementation, it looks like the type constraint must be a union type and requires at least two distinct elements.
So, if the type is A|B, neither A nor B is allowed to be a subtype of the other.

If that condition is satisfied, your utility type works.

When the constraint type is not a union type, or either A or B is a subtype of the other, it breaks.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Aug 16, 2019
@jack-williams
Copy link
Collaborator

The type I provided is almost certainly going to be brittle. In the example:

type UnionX<T extends Constraint> = (
  T extends Constraint ?
  //OK
  NonUnionX<T> : //<-- Expected OK, Actual Error
  never
);

I guess the assumption is that T can't be a union because it's in the body of distributive conditional type, but I don't think there is anyway to convey that information.

IMO, I don't think asking a type whether it is a union is a meaningful question. It would be like asking a number whether it is an addition or multiplication. Semantically, 1 + 3, 2 * 2, and 4, are all the same number that are independent of the notation used to specify them.

Is the type { x: number } | { x: number, y: number } a union? Syntactically yes, but semantically the type is represented by { x: number } . Distributive conditional types do dependent on syntax, which is very useful but not stable (two types that mean the same thing can behave differently). I can see the appeal of taming some of the more complex cases, but I'm not sure adding more constraints that ultimately depend on non-semantic properties is the best approach.

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Aug 17, 2019

I've already given an example of why it's semantically useful.

In particular, many of my use cases happen to be that I need to guarantee type safety for a SQL query builder. And I need to either properly handle union cases or forbid them (if I cannot safely handle union types; either it is impossible or I'm not clever enough to guarantee it yet).

NonUnion types would help increase the safety of the library I'm writing.


One of the simpler examples of it being helpful is asking what the primary key of a table is.

If it is a non union type, then the pk of the table is whatever was defined as the pk (there can only be one per table).

If it is a union type, then we have to ask if it is being used as an input or output.

If it is an input to a function, then the pk is the intersection of all the PKs of each table of the union type.

If it is an output of a function, then it is the union of all PKs of each table of the union type.

Semantically important.


I can dig up more examples of it being useful from my code base, if that example isn't convincing enough.

A lot of things about TS are based on syntax. Sure, they're all brittle but it doesn't make those features any less useful

@AnyhowStep
Copy link
Contributor Author

Asking what the candidate key or super key of a union table type is actually more complex, if used as an input to a function.

Because a table may have more than one candidate key.


If it is not a union type, the CKs are the CKs of the table.

If it is a union type and an output of a function, then it is the union of CKs of each table.

If it is a union type and an input to a function, then the CKs are the... Cross product of each CK from each table.

@AnyhowStep
Copy link
Contributor Author

When trying to build this expression,

//column null-safe eq to value
column <=> value

What are valid types for value?

If column is a not a union type, then value has to be at least a subtype of the column's type.

If column is a union type, then value has to be a subtype of the intersection of each columns' type.

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Aug 17, 2019

In the above cases, it's pretty easy to guarantee type safety, even if union types are passed to a function.

The exact way to guarantee safety is different for each problem (especially when you have correlated type params).

But here's a situation where I can't quite guarantee safety by allowing union types because of how I want the function to be used.

eqCandidateKeyOfTable(
  myTable,
  tableWithCandidateKeys,
  columns => [columns.columnA, columns.columnB]
)

If possible, I want to forbid a union type for myTable. It makes a safety guarantee impossible at the moment.

The other two params may be union types and are fine.


This is safe,

eqCandidateKeyOfTable(
  Math.random() > 0.5 ? myTable0 : myTable1,
  tableWithCandidateKeys,
  columns => [columns.columnA, columns.columnB]
)

Because both columns.columnA, columns.columnB will belong to the same table.


This is not safe,

eqCandidateKeyOfTable(
  Math.random() > 0.5 ? myTable0 : myTable1,
  tableWithCandidateKeys,
  () => [myTable0.columns.columnA, myTable1.columns.columnB]
)

Because the columns are from different tables now.

I could muck around with the type of columns.columnA to make it incompatible with myTable0.columns.columnA in this case, so I guess "impossible" isn't the right word.

But it's extra work that is also brittle

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Aug 17, 2019

In all these cases, the constraint types are not union types. However, the concrete types may be unions.

That sidesteps the issue of the constraint { x: number } | { x: number, y: number } being largely meaningless.

Even then, I don't agree the constraint you gave is meaningless.

Without the { x: number, y: number } part of the constraint, a concrete type of { x: number, y: "hello, world" } may be given. And that would be bad if it is expecting y:number.

Without the { x: number } constraint, { x: number } would not be allowed.

If we change the constraint to { x: number, y?: number }, it may go against the desire that y is either not set, or set to number. As in, y being a property set to undefined is not allowed

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Oct 10, 2019

This is how I'm emulating the non-union constraint at function call sites.

https://github.com/AnyhowStep/tsql/blob/master/src/select-clause/select-delegate.ts#L31

https://github.com/AnyhowStep/tsql/blob/master/src/type-util/is-union.ts#L6

I would still like it at the type level


Just thought I'd elaborate further.

This proposal is different from oneof in this case,

declare function foo<T extends oneof string|number> (t : T) : void;
declare function bar<T extends nonunion string|number> (t : T) : void;

//OK
//"x"|"y" extends string
//string is one of string|number
foo("x" as "x"|"y");

//Error
//"x"|"y" is a union type
bar("x" as "x"|"y");

So, the non-union constraint is basically a stronger version of the one-of constraint

@ShanonJackson
Copy link

function foo(str: number): void
function foo(str: string): void
function foo(str: unknown) {

}

foo("") // fine
foo(5) // fine
foo("" as (number | string)) // error

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Oct 10, 2019

I'm going to copy-paste my response from Gitter,

I've said this many times before but overloads are terrible

foo(arg:{x:string}):void;
foo(arg:{y:string}):void;

foo({x:"",y:""}); //OK, but expected error

The overload thing only really works for simple cases.

foo(arg:oneof {x:string}|{y:string}):void

You can emulate this at function/method call sites at the moment with those fancy StrictUnion/AssertNonUnion/etc. helper types but they don't work when you're working with type aliases only.


Like, >70% of my code is just TS type-level stuff and 30% or less is actual executable code. Emulating stuff at call-sites is only useful for downstream users but not for implementors


A lot of my code has complex conditional types at call-sites for extra type safety (for downstream users) but none of that stuff really helps me as an implementor when I'm composing dozens of type aliases that I know will only work when the input type argument is a non-union type

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants