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 · 30 comments
Open

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

kpdonn opened this issue Apr 25, 2018 · 30 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!

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

@samueldcorbin
Copy link

samueldcorbin commented Sep 18, 2024

The lack of a custom message for invalid/never is one of the biggest remaining problems in library development.

The developer experience of working with well-typed libraries in TypeScript that use practically any conditional types is just godawful. You get completely unreadable error messages, the workarounds are miserable, and the usual solution is just giving up: when someone asks for help understanding a type error from a library, usually people just shrug and tell them that they need to dig through the library's docs and hope they can find a solution because it's just accepted that complex conditional type errors are not readable by most developers.

I can't think of a TypeScript improvement that would do as much good to improve the experience of as many developers - conditional types are just miserable without it.

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.