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

Proposal: new "invalid" type to indicate custom invalid states #23689

Open
kpdonn opened this issue Apr 25, 2018 · 29 comments
Open

Proposal: new "invalid" type to indicate custom invalid states #23689

kpdonn opened this issue Apr 25, 2018 · 29 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@kpdonn
Copy link
Contributor

kpdonn commented Apr 25, 2018

Proposal

A new invalid type that is not assignable to or from any other types. This includes not being assignable to or from any or never. It probably shouldn't even be assignable to invalid itself if that is possible, although I doubt that one really matters. I'd additionally suggest that, unlike other types, invalid | any is not reduced to any and invalid & never is not reduced to never.

The idea is to make sure that there is a compile error any time an invalid type is inferred or otherwise pops up in a users code.

invalid types would come from conditional types to represent cases where the conditional type author either expects the case to never happen, or expects that it might happen but intentionally wants that case to cause a compile error indicating to the user that something is invalid with the code they wrote.

The invalid type should also allow optionally passing an error message that would be displayed to the user when they encounter a compile error caused by the type that could give them a better idea of exactly what the problem is and how to fix it.

Motivating Examples

Allowing either true or false but not boolean - #23493 (comment)

type XorBoolean<B extends boolean> = boolean extends B ? invalid<'only literal true or false allowed'> : boolean

declare function acceptsXorBoolean<B extends boolean & XorBoolean<B>>(arg: B): void

acceptsXorBoolean(true) // allowed
acceptsXorBoolean(false) // allowed

declare const unknownBoolean: boolean
acceptsXorBoolean(unknownBoolean)
// would have error message: 
// Argument of type 'boolean' is not assignable to parameter of type invalid<'only literal true or false allowed'>

It's possible to write the above example today(playground link) using never instead of invalid, but it generates an error message saying: Argument of type 'boolean' is not assignable to parameter of type 'never'. which is very likely to be confusing to a user who encounters it.

Preventing duplicate keys - #23413 (comment)

type ArrayKeys = keyof any[]
type Indices<T> = Exclude<keyof T, ArrayKeys>
type GetUnionKeys<U> = U extends Record<infer K, any> ? K : never
type CombineUnion<U> = { [K in GetUnionKeys<U>]: U extends Record<K, infer T> ? T : never }
type Combine<T> = CombineUnion<T[Indices<T>]>

declare function combine<
  T extends object[] &
    {
      [K in Indices<T>]: {
        [K2 in keyof T[K]]: K2 extends GetUnionKeys<T[Exclude<Indices<T>, K>]> ? invalid<"Duplicated key"> : any
      }
    } & { "0": any }
    >(objectsToCombine: T): Combine<T>


const result1 = combine([{ foo: 534 }, { bar: "test" }]) // allowed

const error1 = combine([{ foo: 534, dupKey: "dup1" }, { bar: "test", dupKey: "dup2" }]) // error

Today(playground link) using never instead of invalid the error message for error1 is:

Argument of type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to parameter of type 'object[] & { "0": { foo: any; dupKey: never; }; "1": { bar: any; dupKey: never; }; } & { "0": any...'.
  Type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to type '{ "0": { foo: any; dupKey: never; }; "1": { bar: any; dupKey: never; }; }'.
    Types of property '"0"' are incompatible.
      Type '{ foo: number; dupKey: string; }' is not assignable to type '{ foo: any; dupKey: never; }'.
        Types of property 'dupKey' are incompatible.
          Type 'string' is not assignable to type 'never'

which would be basically impossible to understand if you didn't expect the function would reject duplicated keys. Using invalid<"Duplicated key"> however the error message could read:

Argument of type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to parameter of type 'object[] & { "0": { foo: any; dupKey: invalid<"Duplicated key">; }; "1": { bar: any; dupKey: invalid<"Duplicated key">; }; } & { "0": any...'.
  Type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to type '{ "0": { foo: any; dupKey: invalid<"Duplicated key">; }; "1": { bar: any; dupKey: invalid<"Duplicated key">; }; }'.
    Types of property '"0"' are incompatible.
      Type '{ foo: number; dupKey: string; }' is not assignable to type '{ foo: any; dupKey: invalid<"Duplicated key">; }'.
        Types of property 'dupKey' are incompatible.
          Type 'string' is not assignable to type 'invalid<"Duplicated key">'

Which gives a very clear hint that the problem is that dupKey is duplicated.

Conditional cases which should never happen

I could also see invalid potentially being used for some conditional types where there is a branch that presumably never gets taken because you are just using the conditional type for the infer capability. For example at the end of #21496 there is a type:

type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R ? R : never;

Maybe invalid<"should never happen"> is used instead of never for the false branch so it's easier to track down the problem if it ever turns out the assumption that the branch will never be taken is wrong. (Of course if T is any, both the true and false branches are always taken so you might not want to change it away from never, but at least there'd be the option)

Related Issues

#20235 - Generics: Cannot limit template param to specific types - Could benefit from an approach like XorBoolean above.
#22375 - how do i prevent non-nullable types in arguments - Solution here is basically the same idea as XorBoolean. The error message for this specific issue is already understandable but it shows there is more interest in the pattern.
#13713 - [feature request] Custom type-error messages - Similar sounding idea, but it seems to be focused on changing the wording of existing error messages.

Search Terms

invalid type, custom error message, generic constraint, conditional types

@kpdonn
Copy link
Contributor Author

kpdonn commented Apr 25, 2018

invalid could also be a solution to the problem of the name global from #18433. Currently it is typed as never and is suggested to be changed to void, but neither of them is a complete solution that protects against accidental usage in every use case. invalid seems like it would prevent accidental usage in all cases though since it is defined to not be assignable to or from anything.

@mhegazy mhegazy added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Apr 25, 2018
@AnyhowStep
Copy link
Contributor

AnyhowStep commented May 12, 2018

I've been playing around with conditional types a lot recently and what I currently do is,

("Some error message"|void|never) for "invalid" types.

Sometimes, I'll add other types to it,

("Unexpected inferred type"|InferredType|void|never)

Obviously, this is not ideal.

For one, it is not always possible to make complicated types work with the above workaround. Sometimes, you simply just have to use never.


I'd like to add an additional suggestion where it would be nice to be able to add information aside from a string error message.

Maybe have the Invalid<> type be a variadic generic,

class Class<A> {};

type Something<ClassT extends Class<any>> = (
    ClassT extends Class<infer A> ?
        (
            A extends number ?
                (9001) :
                (Invalid<"Expected inferred A, ", A, ", of , ClassT, " to extend ", number>)
        ) :
        (Invalid<"ClassT does not extend Class<> or could not infer A", ClassT>)
);

@Kukkimonsuta
Copy link

Kukkimonsuta commented May 22, 2018

Implementation of this proposal would be a great help - combining it with conditional types and generic parameter defaults would allow for pretty precise generic constraints. It might also be worth to introduce a generic discard (_) to make it clear that given generic parameter is only a constraint (and possibly make it impossible to override it):

interface AcceptRequiredValue<
    T,
    _ = undefined extends T ? invalid<'undefined is not allowed'> : never,
    _ = null extends T ? invalid<'null is not allowed'> : never
> {
    value: T;
}

@AnyhowStep
Copy link
Contributor

I'm on my phone right now but I have some types I use as error states at the moment,

//Just using `Error` as an invalid type marker but it's just an ugly hack
type Invalid1<T0> = [T0]|void|Error;
type Invalid2<T0, T1> = [T0, T1]|void|Error;
type Invalid3<T0, T1, T2> = [T0, T1, T2]|void|Error;
/*Snip*/

It works well enough for now but is unwieldy with more complicated types because it doesn't behave like never

@jcalz
Copy link
Contributor

jcalz commented Jul 24, 2018

I just have to say that almost every time I use a conditional type I wish this proposal or something like it were implemented. If I could give this issue more than one 👍 I would.

@bcherny
Copy link

bcherny commented Aug 13, 2018

Another use case (also, proposing a type-level throw instead of introducing a new type, to indicate that we're now in a bad state and should break out of normal typechecking and mark downstream values as never):

function head<
  T extends any[],
  R = T[0] extends never
    ? throw 'Cant read head of empty array'
    : T[0]
>(array: T): R {
  return array[0]
}

let a = head([1,2,3]) // number
let b = head([]) // Error: Cant read head of empty array
let c = b + 5 // never

#26400

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Aug 13, 2018

@jack-williams
Copy link
Collaborator

Would it be possible to view invalid as having a different 'kind' to ordinary TypeScript types? (Say Err rather than * as per Haskell). No run-time value has a type of kind Err, so anytime it pops up in something like an argument position we'll get a type error.

@jack-williams
Copy link
Collaborator

It probably shouldn't even be assignable to invalid itself if that is possible, although I doubt that one really matters.

I think it does matter, otherwise a conditional type with invalid could never be assignable to itself, or conditional types that are the same:

type Foo<T> = T extends 1 ? string : invalid<"some message">;
type Bar<T> = T extends 1 ? string : invalid<"some other message">;

function foo<T>(x: Foo<T>, y: Bar<T>) {
  x = y;  
  // ^ Error
  //    Type 'Bar<T>' is not assignable to type 'Foo<T>'.
  //    Type 'invalid<"some other message">' is not assignable to type 'invalid<"some message">'.

  x = x;
  // ^ Error
  //    Type 'Foo<T>' is not assignable to type 'Foo<T>'.
  //    Type 'invalid<"some message">' is not assignable to type 'invalid<"some message">'.
}

both assignments would be illegal if invalid was not assignable to itself because assignment for conditional types is done through congruence.

@Kinrany
Copy link

Kinrany commented Oct 31, 2018

Perhaps just having a variable with a type that allows invalid should produce a type error?

In the example above I'd expect the signature function foo<T>(x: Foo<T>, y: Bar<T>) to be illegal because T does not extend 1.

Alternatively TypeScript could automatically constrain T so that no variable could possibly have a type that allows invalid.

@m93a
Copy link

m93a commented Nov 11, 2018

This would really like to see this implemented, ideally as type-level throw. I use really thorough type-checking and the errors often look very confusing and unintuitive.

The way I handle invalid types in my code is something like this:

const StringExpected = {
  'TypeError: At least one of the arguments has to be string': Symbol()
};

function foo<T extends any[]>(
  ...args: UnionToIntersection<T[number]> extends string
           ? T
           : typeof StringExpected[]
) {}

foo(4, Symbol(), {}); // [ts] Argument of type '4' is not assignable to parameter of type '{ 'TypeError: At least one of the arguments has to be string': symbol; }'.
foo(4, Symbol(), ''); // OK

Finally, I'd just like to add some keywords, so that people are more likely to find the issue:

Keywords: custom compile time error, custom early error, throw type, custom invalid type, throw in type declaration, conditional type error

@AnyhowStep
Copy link
Contributor

I figured I'd drop my current hack-y workaround for compile-time error messages over here.

It relies on how unknown behaves with type intersection, and conditional types.

The benefit of this workaround is that you do not pollute the return type unnecessarily.

The drawback is that calling functions that use this workaround (especially in a generic context) may be a little more tiresome.


type ErrorCheck<T extends number> = (
    Extract<42|69|1337, T> extends never ?
    unknown :
    ["You cannot use", T, "as an argument"]
);
declare function foo<T extends number>(
    n : T & ErrorCheck<T>
): string
/*
    Argument of type '42' is not assignable to parameter of type '42 & ["You cannot use", 42, "as an argument"]'.
    Type '42' is not assignable to type '["You cannot use", 42, "as an argument"]'.
*/
foo(42);

//OK
foo(32);

/*
    Argument of type 'number' is not assignable to parameter of type 'number & ["You cannot use", number, "as an argument"]'.
    Type 'number' is not assignable to type '["You cannot use", number, "as an argument"]'.
*/
declare const n: number;
foo(n);

declare const n2: 42 | 69 | 78;
//Long, ugly, error message
foo(n2);

///// Chaining calls/Generics

function bar<T extends number>(n: T) {
    //NOT OK; Long, ugly, error message
    return foo(n);
}

function baz<T extends number>(n: T & ErrorCheck<T>) {
    //Still NOT OK; Long, ugly, error message
    return foo(n);
}

function buzz<T extends number>(n: T & ErrorCheck<T>) {
    //OK!
    return foo<T>(n)
}

Playground

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Feb 26, 2019

desperately need a special type (let's call it invalid) that would stop type checking anyfurther if gets deduced in a attempt to resolve a generic:

function f<A>(something: A): string {
    type A extends { text: string; } ? A : invalid; // might need to stop here if A turns out to be bad
    return something.text; // if we got here then A is { text: string; }
}

@m93a
Copy link

m93a commented Mar 5, 2019

@Aleksey-Bykov Why don't you just do this?

function f<A extends { text: string; }>(something: A): string {
    return something.text;
}

@zpdDG4gta8XKpMCd
Copy link

well because my main use case is inferring types from generics via infer ..., and you cannot put constraints on a inferred type

@DrSammyD
Copy link

DrSammyD commented Mar 6, 2019

+1

This would be great for creation of an XOR type, because the errors otherwise are unreadable unless you do the following.

type Without<T, U> = {
    [P in Exclude<keyof T, keyof U>]?: ["Property", P, "from type", T, "is mutually exclusive with the following properties in type", U, keyof U]
};

type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

interface Y { y: number };
interface Z { z: string };
var x: XOR<Y, Z> = {
    y: 2,
    z: ""
}

Playground

The following would be preferable. I'd just suggest sticking with interpolation syntax for consistency.

type Without<T, U> = {
    [P in Exclude<keyof T, keyof U>]?: throw `Property ${P} from type ${T} is mutually exclusive with the following properties in type ${U} : ${keyof U}`
};

If this syntax were accepted, a future addition could be a handle to the current instance of the object.

throw `Property ${P} from type ${T} is mutually exclusive with the following properties ${keyof Extract<U, typeof this>}`

This would restrict the error down to the properties actually used by the inline type.

@ds300
Copy link
Contributor

ds300 commented Mar 19, 2019

1000x yes to this.

As a library author it's tempting to go wild with the expressivity of TS and end up producing some lovely useful safe abstractions... which give awful unhelpful type error messages when users make mistakes. So I will often trade off nice abstractions in favour of nice error messages.

This feature would mean I could avoid making those tradeoffs. I could give users wild, beautiful, feels-good-man type abstractions at the same time as giving users clear, domain-specific error messages.

@wongjiahau
Copy link

I figured I'd drop my current hack-y workaround for compile-time error messages over here.

It relies on how unknown behaves with type intersection, and conditional types.

The benefit of this workaround is that you do not pollute the return type unnecessarily.

The drawback is that calling functions that use this workaround (especially in a generic context) may be a little more tiresome.

type ErrorCheck<T extends number> = (
    Extract<42|69|1337, T> extends never ?
    unknown :
    ["You cannot use", T, "as an argument"]
);
declare function foo<T extends number>(
    n : T & ErrorCheck<T>
): string
/*
    Argument of type '42' is not assignable to parameter of type '42 & ["You cannot use", 42, "as an argument"]'.
    Type '42' is not assignable to type '["You cannot use", 42, "as an argument"]'.
*/
foo(42);

//OK
foo(32);

/*
    Argument of type 'number' is not assignable to parameter of type 'number & ["You cannot use", number, "as an argument"]'.
    Type 'number' is not assignable to type '["You cannot use", number, "as an argument"]'.
*/
declare const n: number;
foo(n);

declare const n2: 42 | 69 | 78;
//Long, ugly, error message
foo(n2);

///// Chaining calls/Generics

function bar<T extends number>(n: T) {
    //NOT OK; Long, ugly, error message
    return foo(n);
}

function baz<T extends number>(n: T & ErrorCheck<T>) {
    //Still NOT OK; Long, ugly, error message
    return foo(n);
}

function buzz<T extends number>(n: T & ErrorCheck<T>) {
    //OK!
    return foo<T>(n)
}

Playground

Thanks a lot!

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Sep 15, 2019

I'll be updating this comment over the period of a few hours with my thoughts on how my ideal implementation would behave.

I'll be using the CompileError<> syntax.


Use Case

The all-important question.

In the examples that follow, demonstrating intended behaviour, the examples will all be contrived.

Just so the usefulness of this feature isn't in doubt, you may refer to this link for a list of real and complex use cases.

https://github.com/AnyhowStep/tsql/search?l=TypeScript&p=2&q=CompileError

In a project I'm working on, the CompileError<> type is used to emulate a subset of features of the desired invalid type.

The library performs compile-time checks on SQL queries and ensures they are always syntactically valid and type-safe, when executed.


Intended Behavior (in my opinion),

Type Alias Behaviour

The CompileError<> type should result in an actual compile error when instantiated concretely.

In generic contexts, no compile error is given.

//CompileError type instantiated concretely
//leads to actual compile error
type E0 = CompileError<["description", 0]>;
//        ~~~~~~~~~~~~ Custom compile error encountered: ["description", 0]

//CompileError type in generic context
//does not lead to actual compile error
type E1<T> = CompileError<T>;

//CompileError type in generic context
//does not lead to actual compile error
type E2<T> = CompileError<"a">;


//CompileError type instantiated concretely
//leads to actual compile error
type E3 = E1<void>;
//        ~~ Custom compile error encountered: void

//CompileError type in generic context
//does not lead to actual compile error
type NoNumber<T> = Extract<T, number> extends never ?
    T :
    CompileError<["The following types are not allowed", Extract<T, number>]>;

//CompileError type instantiated concretely
//leads to actual compile error
type E4 = NoNumber<string|1|2n|(3&{x:4})>;
//        ~~~~~~~~ Custom compile error encountered: ["The following types are not allowed", 1|(3&{x:4})]

//Conditional type resolves to string|1337n
//So, no compile error
type NoError0 = NoNumber<string|1337n>;

Conditional Type Branch Behaviour

If the conditional type can be evaluated immediately (not deferred because of a generic type param),
then we ignore CompileError<> types in unevaluated branches.

//Resolves to `CompileError<"n">`
type E0 = number extends string ? "y" : CompileError<"n">;
//                                      ~~~~~~~~~~~~ Custom compile error encountered: "n"

//Resolves to `"n"`
//No compile error because true branch is not evaluated
type E1 = number extends string ? CompileError<"y"> : "n";

//Resolves to `"y"`
//No compile error because false branch is not evaluated
type E2 = "beep" extends string ? "y" : CompileError<"n">;

//Resolves to `CompileError<"y">`
type E3 = "beep" extends string ? CompileError<"y"> : "n";
//                                ~~~~~~~~~~~~ Custom compile error encountered: "y"

//Resolves to `CompileError<"y">|CompileError<"n">`
type E4 = any extends string ? CompileError<"y"> : CompileError<"n">;
//                             ~~~~~~~~~~~~ Custom compile error encountered: "y"
//                                                 ~~~~~~~~~~~~ Custom compile error encountered: "n"

If the conditional type is deferred, then we ignore CompileError<> on both branches.

function foo<T> (t : T) {
    //No compile error
    //Evaluation deferred
    type E0 = T extends string ? CompileError<"y"> : CompileError<"n">;

    //Resolves to CompileError<"n">
    type E1 = number extends string ? "y" : CompileError<"n">;
    //                                      ~~~~~~~~~~~~ Custom compile error encountered: "n"

    //Resolves to CompileError<T>
    type E2 = number extends string ? "y" : CompileError<T>;
    //                                      ~~~~~~~~~~~~ Custom compile error encountered: T

}

extends Behaviour

The types never and CompileError<> are considered sub types of each other, for the purpose of type checking.

The rationale is that the code should not compile successfully, let alone run, when a concrete custom compile error is encountered.

So, a value of type CompileError<> should essentially never exist.

//Gives two compile errors but resolves to "y"
type X0 = CompileError<"a"> extends CompileError<"b"> ? "y" : "n";
//        ~~~~~~~~~~~~ Custom compile error encountered: "a"
//                                  ~~~~~~~~~~~~ Custom compile error encountered: "b"
    

//Gives a compile error but resolves to "y"
type X1 = CompileError<"a"> extends never ? "y" : "n";
//        ~~~~~~~~~~~~ Custom compile error encountered: "a"

//Gives a compile error but resolves to "y"
type X2 = never extends CompileError<"b"> ? "y" : "n";
//                      ~~~~~~~~~~~~ Custom compile error encountered: "b"

function foo<T> (t : T) {
    //Gives two compile errors but resolves to "y"
    type X3 = CompileError<"a"> extends CompileError<"b"> ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: "a"
    //                                  ~~~~~~~~~~~~ Custom compile error encountered: "b"
            

    //Gives a compile error but resolves to "y"
    type X4 = CompileError<"a"> extends never ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: "a"

    //Gives a compile error but resolves to "y"
    type X5 = never extends CompileError<"b"> ? "y" : "n";
    //                      ~~~~~~~~~~~~ Custom compile error encountered: "b"

    //Gives two compile errors but resolves to "y"
    type X6 = CompileError<T> extends CompileError<"b"> ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: T
    //                                ~~~~~~~~~~~~ Custom compile error encountered: "b"

    //Gives a compile error but resolves to "y"
    type X7 = CompileError<T> extends never ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: T

    //Gives a compile error but resolves to "y"
    type X8 = never extends CompileError<T> ? "y" : "n";
    //                      ~~~~~~~~~~~~ Custom compile error encountered: T

    //Resolves to `CompileError<"nope">`
    type X9 = number extends string ? "y" : CompileError<"nope">;
    //                                      ~~~~~~~~~~~~ Custom compile error encountered: "nope"

    //Resolves to "y"
    //No compile error
    type X10 = number extends number ? "y" : CompileError<"nope">;

    //Deferred, no compile error
    type X11 = T extends any ? CompileError<"yeap"> : CompileError<"nope">;

    //Deferred, no compile error
    type X12 = T extends CompileError<T> ? CompileError<"yeap"> : CompileError<"nope">;
}

Type Parameter Behaviour

The CompileError<T> type may be used as a default type argument without triggering an actual compile error. This can be used to force users to specify an explicit type parameter.

I have seen this requested over Gitter many times. Google might also somewhat benefit from it, with their return-only generics.

#33272

//No compile error when used as
//default type argument
type Explicit<T=CompileError<"Explicit type argument required">> = T;

declare const explicit0 : Explicit;
//                        ~~~~~~~~ Custom compile error encountered: "Explicit type argument required"

//No compile error
declare const explicit1 : Explicit<void>;

declare class MySet<T=CompileError<"Explicit type argument required">> () {
    add (t : T) : void;
    get () : T;
}
const mySetErr = new MySet();
//                   ~~~~~ Custom compile error encountered: "Explicit type argument required"

const mySetOk = new MySet<number>(); //OK!

Because CompileError<> is also a bottom type like never, we can have this,

//No compile error when used as
//default type argument
//`CompileError<>` is subtype of all types like `never`
type Explicit<T extends number=CompileError<"Explicit type argument required">> = T;

declare const explicit0 : Explicit;
//                        ~~~~~~~~ Custom compile error encountered: "Explicit type argument required"

declare const explicit1 : Explicit<void>;
//                                 ~~~~ `void` is not assignable to `number`

//No compile error
declare const explicit2 : Explicit<32>;

declare function foo<T extends number=CompileError<"Explicit type argument required">> () : ComplicatedType<T>;
const x = foo();
//        ~~~ Custom compile error encounted: "Explicit type argument required"

const y = foo<9001>(); //OK

Variable Behaviour

Different CompileError<> types are assignable to each other, since they all function as never,

declare let a : CompileError<"a">;
//              ~~~~~~~~~~~~ Custom compile error encountered: "a"
declare let b : CompileError<"b">;
//              ~~~~~~~~~~~~ Custom compile error encountered: "b"

a = b; //OK
b = a; //OK

declare let foo : <T>(t : T) => T extends number ? CompileError<"haha, no numbers plz"> : T;
declare let bar : <T>(t : T) => T extends number ? CompileError<"numbers not welcome!"> : T;
declare let baz : <T>(t : T) => T extends number ? never                                : T;

foo = bar; //OK
foo = baz; //OK
bar = foo; //OK
bar = baz; //OK
baz = foo; //OK
baz = bar; //OK

Property Behaviour

interface IFoo<T> {
    //No compile error
    prop : T extends number ? CompileError<[T, "not allowed"]> : T;
}

const x : IFoo<number> = { prop : 3 };
//        ~~~~ Custom compile error encountered at property 'prop': [number, "not allowed"]
//                                ~ `3` is not assignable to `CompileError<[number, "not allowed"]>`

Parameter Behaviour

Custom compile errors at function parameters can be emulated at the moment,
but are hacky and messy.

You can see the hack in action through this link,
https://github.com/AnyhowStep/tsql/search?l=TypeScript&p=2&q=CompileError

//CompileError type in generic context
//does not lead to actual compile error
type AssertNonNumber<T> = Extract<T, number> extends never ?
    T :
    CompileError<["The following types are not allowed", Extract<T, number>]>;

declare function foo<T>(t : T & AssertNonNumber<T>) : T;

foo("hi"); //OK

foo(54);
//  ~~ Custom compile error encountered: ["The following types are not allowed", 54]

//"hi"
const hi = foo("hi"); //OK

//54
const n = foo(54);
//            ~~ Custom compile error encountered: ["The following types are not allowed", 54]

function bar<T> (t : T) : T {
    return foo<T>(t);
    //         ~ `T` is not assignable to `T & AssertNonNumber<T>`
}

function baz<T> (t : T & AssertNonNumber<T>) : T {
    return foo<T>(t); //OK
}

function baz<T> (t : T & AssertNonNumber<T> & SomeOtherCheck<T>) : T {
    return foo<T>(t); //OK
}

Return Type Behaviour

At the moment, there is no workaround to enable real return type compile error behaviour.

declare foo<T> (t : T) : T extends number ? CompileError<["number", T, "not allowed"]> : T

foo("hi"); //OK

foo(54); //Custom compile error encountered: ["number", 54, "not allowed"]

//"hi"
const hi = foo("hi"); //OK

//CompileError<["number", 54, "not allowed"]>
const n = foo(54); //Custom compile error encountered: ["number", 54, "not allowed"]

Sample Complex Use Case

The following complex use case comes from a project I'm working on,

https://github.com/AnyhowStep/tsql/blob/e7721dbda5e99acf77a90eeb148e8ade3313fbd2/src/on-clause/util/predicate/assert-no-outer-query-used-ref.ts#L56-L72


In MySQL, the following query is invalid,

SELECT
    *
FROM
    myTable
WHERE
    (
        SELECT
            myTable2.myTable2Id
        FROM
            myTable2
        JOIN
            myTable3
        ON
            -- myTable.myTableId is an outer query column
            myTable3.myTable3Id = myTable.myTableId
        LIMIT
            1
    ) IS NOT NULL

A subquery cannot reference outer query columns in the ON clause in MySQL.

The above query will result in a compile-time error,

CompileError<[\"ON clause must not reference outer query tables\", \"myTable\"]>

@Jack-Works
Copy link
Contributor

I'm working on this with PR #40336, I think it will be super powerful.

@Jack-Works
Copy link
Contributor

PR at #40402

@shicks
Copy link
Contributor

shicks commented Aug 25, 2022

I would love to see this move forward. As a library developer, I regularly want some way to force a type error, and the current solutions are spotty (e.g., as outlined above, there's not a good way to indicate a bad return type, and weirdly-wrapped parameters can only get you so far, and then provide awkward errors at best).

@shicks
Copy link
Contributor

shicks commented Aug 29, 2022

It's possible that a small extension of the new satisfies keyword could go a long way to making this work. If you could write

type IsStringLiteral<T extends string> = string extends T ? never : T;
type Foo<T satisfies IsStringLiteral<T>> = ...;

then you could write Foo<'abc'> because IsStringLiteral<'abc'> is just 'abc', while Foo<string> would give an error

TS2344: Type 'string' does not satisfy the constraint 'IsStringLiteral<string>'.

Here's a more concrete motivation (playground):

declare function isEnum<T extends IsEnum<T>>(enumContainer: T): (arg: unknown) => arg is T[keyof T];
type IsEnum<T, E = T[keyof T]> =
    [E] extends [string] ? (string extends E ? never : unknown) :
    [E] extends [number] ? (
        true extends ({[key: number]: true} & {[P in E]: false})[number] ?
            unknown : never) :
    never;

I want to lock down isEnum to only be allowed to pass in an enum container - but currently this gives an error that the type parameter T has a circular constraint, since it's trying to treat IsEnum<T> as both a lower bound (which affects inference) and a checkable constraint. Just like how the new satisfies is only a check (compared to as, implements, and extends, which all actually affect inference), if it were allowed in this position

declare function isEnum<T satisfies IsEnum<T>>(enumContainer: T): (arg: unknown) => arg is T[keyof T];

then the type checker could still check the constraint but not establish a cycle.

@ryb73
Copy link

ryb73 commented Nov 11, 2022

Since a fix hasn't been pushed to TS yet, I decided to publish https://www.npmjs.com/package/invalid-type. It's inspired by the discussion in this thread and fixes some use cases I encountered myself (to be honest I only skimmed this thread, so there may be valuable contributions here that didn't make their way into my package). Feel free to contribute if you have use cases that aren't handled.

@shicks
Copy link
Contributor

shicks commented Jan 30, 2023

I consider this part of a handful of related issues needed for library-friendly type checking.

I recently put together a "wish list" (see this gist) for a few features that I'd like to see in TypeScript generics, and that have some pretty solid synergy (such that any one by itself may not be particularly compelling, but when combined with the others, it makes some significant improvements).

These are clearly relevant issues that a lot of people would like to see addressed. It would be great if we could get some more eyes on them, particularly from the TypeScript maintainers.

@adamscybot
Copy link

adamscybot commented Apr 19, 2024

This is sorely needed. Since the introduction of Template Literal Types and the associated level of complexity that this can lead to, it's very frustrating in a lib scenario to only be able to natively present never when something does not match.

I wonder whether never itself could be extended. For example, never<'Custom error message'>. It would have the advantage of not introducing entirely new semantics (error messaging would be restricted specifically to never) and would be familiar, though as always with this type of thing you can easily inadvertently introduce a gazillion unforeseen gotchas.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.