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

Throw types #40468

Draft
wants to merge 22 commits into
base: master
from
Draft

Throw types #40468

wants to merge 22 commits into from

Conversation

@Jack-Works
Copy link
Contributor

@Jack-Works Jack-Works commented Sep 10, 2020

Fixes #23689

This PR introduces:

  1. A new type-level expression: throw type_expr. Currently throw type only throws when it is being instantiated.
  2. A new modifier to use in the template string typeof T to make it easier to debug. (Removed due to #40580)

Considered use cases:

Welcome to suggest more use cases!

Due to typeof modifier removed, some of the following examples are no longer working!

TypeAlias instantation

type MustNumber<T> = T extends number ? T : throw `Expected, but found "${typeof T}"`
type A = MustNumber<1>
type B = MustNumber<typeof window>

image

Prevent CallExpression

function checkedDivide<T extends number>(x: T): T extends 0 ? throw 'Cannot divided by 0' : number {
    if (x === 0) throw new Error('')
    return 5 / x
}
checkedDivide(0)
checkedDivide(1)

const theAnswerToEverything = <T>(x: T): T extends 42 ? T : throw "Wrong" => x
theAnswerToEverything(42 as const)
theAnswerToEverything('')

function checkParameterPosition<T extends number>(y: T extends 1234 ? throw 'No zero' : T) { }
checkParameterPosition(1234)
checkParameterPosition(12345678)

image

Prevent use of identifiers

image

TODO:

  1. Figure out how to "print" types in the error message. Maybe a new modifier \${typeof T}``
  2. Write tests and fix broken parts
  3. Wait for #40336 to be merged
@mmkal
Copy link

@mmkal mmkal commented Sep 10, 2020

Looking forward to trying out with the fix - not sure if I'm allowed to do this as a non-contributor but:

@typescript-bot pack this.

@orta
Copy link
Member

@orta orta commented Sep 10, 2020

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

@typescript-bot typescript-bot commented Sep 10, 2020

Heya @orta, I've started to run the tarball bundle task on this PR at d69f2b0. You can monitor the build here.

@typescript-bot
Copy link
Collaborator

@typescript-bot typescript-bot commented Sep 10, 2020

Hey @orta, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/84753/artifacts?artifactName=tgz&fileId=458CBB664D3CE9EFA165B438F01414D55349C27455F3F7CE2CC288A173FC410B02&fileName=/typescript-4.1.0-insiders.20200910.tgz"
    }
}

and then running npm install.


There is also a playground for this build.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 10, 2020

Idea: Find a way to "match" the error string into the typescript diagnostic object, to reuse the translation of diagnostic message.

A possible solution:

throw {diagnostic: "Did_you_mean_0", args: [T] }

That will match the Diagnostic.Did_you_mean_0 and therefore it will be automatically translated by the compiler.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 10, 2020

Idea: Find a way to emit other kinds of diagnostic, like suggestions or warning. If so, there should be another mechanism to offer a underlying type like

throw {message: T, type: T2, kind: "suggestion"}
@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 10, 2020

Oh I just realized we can throw a object type so it can include more details! Please help me to investigate the possibility of this to find a good shape of the error object.

Possible properties:

  • underlying type (what should this throw type resolve to)
  • diagnosis level (error, suggestions, warning, deprecation)
  • reference to typescript built in diagnosis message to provide translated message
  • provide refactor suggestions
@@ -3032,6 +3032,10 @@
"category": "Error",
"code": 2793
},
"Type instantiated results in a throw type saying: {0}": {

This comment has been minimized.

@dsherret

dsherret Sep 11, 2020
Contributor

Something shorter? Type instantiation threw. {0}?

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 11, 2020

Idea: Allow throw type to be handled by conditional type. This allows some error recovery or composing multiple errors.

T extends throw infer E1 ?
    U extends throw infer E2 ?
        throw `\n    ${E1}\n    ${E2}`
    : T : never
@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 11, 2020

import type { DiagnosticCategory } from 'typescript'
/**
 * Since this is a type-level thing, any union in this type is considered invalid value
 * cause their value is not determinate yet.
 * 
 * `throw "string"` is convert to `throw { message: "string" }`
 */
type ErrorMessage = {
    /**
     * ! implemented !
     * If diagnostic is not exist or not valid (key not found / not a tuple) this message will be used
     * 
     * If it is not a string literal type, it will be formatted by `getTypeNameForErrorDisplay`
     */
    message: any
    /**
     * What type should this throw type compatible(equal) with?
     * Useful to "add" diagnostic message on a type and preserve itself
     * @default never
     */
    type?: any
    /**
     * ! implemented !
     * TODO: provide completion for this in the language service
     * Actually it is `[keyof Diagnostic (a @internal variable of ts), ...any[]]`
     * @example ["_0_expected", T] results in "T expected" (with translation in different languages)
     */
    diagnostic?: [type: string, ...args: any[]]
    /**
     * ! implemented !
     * @default `Error` when undefined. `Message` when value is invalid.
     */
    category?: 'suggestion' | 'error' | 'warning' | 'message'
    /**
     * It seems like deprecated is not in the DiagnosticCategory
     */
    deprecated?: boolean

    // ! Let developers custom the error code might not a good idea.
    // code: number

    /**
     * When it happened on an identifer, replace the identifier with the suggestion
     * e.g.: name => window.name
     * 
     * When it happened on a type alias, do nothing
     * When it happened on a CallExpression, do nothing
     * 
     * ? Is this really useful cause the throw cannot get the original source text ?
     * 
     * If we have higher kind types, this option can receive a un-instantiated generic type
     * as a type-level function.
     * 
     * @example
     * type MyError<Context extends ...> = ...
     * type T<U> = ... extends ... ? ... : throw {message: ..., suggestion: MyError}
     */
    suggestion?: string

    /**
     * Let message be able to chain
     */
    next?: ErrorMessage | ErrorMessage[]
}
@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 11, 2020

Idea: Find a way to "match" the error string into the typescript diagnostic object, to reuse the translation of diagnostic message.

A possible solution:

throw {diagnostic: "Did_you_mean_0", args: [T] }

That will match the Diagnostic.Did_you_mean_0 and therefore it will be automatically translated by the compiler.

image

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 11, 2020

Idea: Find a way to emit other kinds of diagnostic, like suggestions or warning. If so, there should be another mechanism to offer a underlying type like

throw {message: T, type: T2, kind: "suggestion"}

@RyanCavanaugh RyanCavanaugh marked this pull request as draft Sep 11, 2020
@Harpush
Copy link

@Harpush Harpush commented Sep 11, 2020

To be honest the initial idea is great - but adding diagnostics types and formatting feels like an overkill. It might even step into the linters world. The syntax is more complex too. I think having the ability to throw is already great as is.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 11, 2020

The syntax is more complex too.

Hmm, I didn't introduce a new syntax for a "detailed" throw. It's a plain object literal type. I need some advice from the TypeScript team. If they think it's no need to do this, I'll stop working on that feature sets and focus on what will be accepted.

Now I still have two complex ideas:

Is there anything I mentioned above that the TS team doesn't want? cc @RyanCavanaugh @orta @Kingwl

@mmkal
Copy link

@mmkal mmkal commented Sep 12, 2020

Just as a data point - this PR makes my expect-type library for writing type-level assertions considerably better. The implementation no longer depends on rest-parameter hacks, and the error messages are much more useful.

Error messages before:
image

Error messages after:
image


Re being able to use diagnostic objects, I'm inclined to agree with @Harpush - plain string messages being supported is a must-have, but supporting complex objects, probably needs more design and consideration, and are more of a nice-to-have. I'd expect trying to include them from the get-go will make it harder for this PR to be accepted - so purely selfishly, as someone who wants to use some form of this feature ASAP, my two cents: complex diagnostic object support should be split out into another PR.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 13, 2020

Thanks! I have already paused my work on the complex diagnostic messages and waiting for the TS team language meeting to decide.
Now currently I'm focusing on improving the basic throw string case. If they don't want the complex diagnostic feature I'll remove them later.

@microsoft microsoft deleted a comment from typescript-bot Sep 14, 2020
@jack-williams
Copy link
Collaborator

@jack-williams jack-williams commented Sep 15, 2020

Should this work?

type MustNumber<T> = T extends number ? T : throw `"Expected, but found "${typeof T}"`
type MustNumber2<T> = T extends number ? T : throw `"Expected, but found "${typeof T}"`

function foo<T>(x: MustNumber<T>, y: MustNumber2<T>) {
    x = y; // error now
}

Playground

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 16, 2020

hi, @jack-williams the playground has been outdated. The latest error message is on x in x = y saying:

Type 'MustNumber2<T>' is not assignable to type 'MustNumber<T>'.
  Type 'T | never' is not assignable to type 'MustNumber<T>'.
    Type 'number & T' is not assignable to type 'MustNumber<T>'.
      Type 'never' is not assignable to type 'never'. Two different types with this name exist, but they are unrelated.ts(2322)

I'll try to handle this later Fixed. @orta Hi, can you let the bot to re-pack the latest commit?

@mohe2015
Copy link

@mohe2015 mohe2015 commented Sep 17, 2020

Can somebody pack this again please? And also could it be possible that printing unions is not possible?

@mohe2015
Copy link

@mohe2015 mohe2015 commented Sep 17, 2020

Also I try to type a database migration using the following code:

let a = {
    posts: {
        content: {

        },
        name: {}
    }
}

let b = {
    posts: {
        content: {
            
        },
        namfe: {}
    }
}

type SafeMerge<A, B> =
{
    [K in Exclude<keyof A, keyof B>]: A[K]
}
&
{
    [K in Exclude<keyof B, keyof A>]: B[K]
}
&
{
    [K in keyof A & keyof B]: 
        keyof A[K] & keyof B[K] extends never ?
        {
            [K1 in keyof A[K]]: A[K][K1]
        }
        &
        {
            [K1 in keyof B[K]]: B[K][K1]
        }
        : throw `the following additions contain already existing columns: ${K}.${typeof B[K]}}`
}

let fdsfione: SafeMerge<typeof a, typeof b> = null as any

fdsfione.posts

In this case a nonlazy error would be useful. Do you have another solution and if not would you be interested in implementing this? Or do you think it's not worth it for such special usages?

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 17, 2020

Hi @mohe2015, since this is a type-level operation, I think I have no idea about how to handle union cause it is representing "one of" those errors. Maybe intersections can be represented as multiple errors.

Your latter example does work on the latest commit, error on fdsfione.posts says:

Type instantiated results in a throw type saying: 
    the following additions contain already existing columns: posts.{ content: {}; namfe: {}; }}
ts(2794)

Oh do you mean it should error on let fdsfione? I'll investigate it later

@mohe2015
Copy link

@mohe2015 mohe2015 commented Sep 17, 2020

@Jack-Works thanks for the fast reply. My initial idea was to do

throw `the following additions contain already existing columns: ${K}.${keyof A[K] & keyof B[K]}`

which would produce a really nice error message if only one column is duplicate. But it fails for multiple ones. The problem with the current solution (in my last comment) is that it also shows unrelated objects that aren't duplicates and also shows all children which are unrelated to the problem.

Regarding the second part - yes I would love if it could error on let fdsfione although there may be completely different solutions to my problem I don't know of.

@orta
Copy link
Member

@orta orta commented Sep 17, 2020

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

@typescript-bot typescript-bot commented Sep 17, 2020

Heya @orta, I've started to run the tarball bundle task on this PR at 56bfcd7. You can monitor the build here.

@typescript-bot
Copy link
Collaborator

@typescript-bot typescript-bot commented Sep 17, 2020

Hey @orta, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/85710/artifacts?artifactName=tgz&fileId=08A5EC3BE4780859520BB416BFE0B51DDB7C552F415DA933BD0766A1E2060B9E02&fileName=/typescript-4.1.0-insiders.20200917.tgz"
    }
}

and then running npm install.


There is also a playground for this build.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 19, 2020

@mohe2015 I have re-investigate your case, it is by design. Because your throw type is on the object property, the SafeMerge itself never returns a throw type. So, it will only throw when you access the property that is marked as to throw type. And also, declaration of throw type doesn't make the declaration itself invalid. It makes all access to it invalid.

I have improved the intersection case. Any (throw T) & U will be eliminated to throw T (the first throw type in the intersection).

@jack-williams
Copy link
Collaborator

@jack-williams jack-williams commented Sep 19, 2020

@Jack-Works In the case of this example playground,

Type 'MustNumber2<T>' is not assignable to type 'MustNumber<T>'.

my concern was not with the error message, but rather that I think it is wrong that two identical types are not assignable to each other.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 19, 2020

@jack-williams Now currently I didn't handle the assignability of throw types, which means they behave like unique symbol, every throw type is not assignable to each other even they containing the same error message. I don't know if throw type should be structural type checked.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 22, 2020

Merged with master, due to #40580, now the typeof T string modifier (not type operator!) has been removed. Maybe I'll add it back as an new intrinsic string mapping type later?

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 27, 2020

#40779

Hi, I have read the meeting note! I have something to reply to and question about.

  • Issue: "if it ever gets evaluated" leaks implementation details of instantiation.
    • Our type system's instantiation semantics aren't strictly evaluated, but it's not always lazily evaluated either!
      • Basically non-strict is the best you can say.
    • Lots of places where instantiation can also be surprising (e.g. looking at constraints).

My early implementation does work on type instantiation, but I really found some problems (type get instantiation at the position I don't want to throw errors). The current implementation is case-by-case to raise errors on the evaluated type (for example, PropertyAccess, Identifier access, a function call, ...etc).

  • Two potential directions of "signaling never" and signaling "anti-any".

I don't understand what is "anti-`any`". But the type currently work in this way: it is printed as "never" (but actually not so it is not compatible with never.). It behaves like unique symbol cause I don't know how they should be compared about.

Question:

  • Does the team discussion about the complex throw type? (Partly implemented now #40468 (comment)) Should I complete this or revert this?
  • Should throw type supports conditional types and infer?
  • What should I change next?

@DanielRosenwasser

@DanielRosenwasser
Copy link
Member

@DanielRosenwasser DanielRosenwasser commented Sep 29, 2020

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

@typescript-bot typescript-bot commented Sep 29, 2020

Heya @DanielRosenwasser, I've started to run the tarball bundle task on this PR at cdd5cd0. You can monitor the build here.

@DanielRosenwasser
Copy link
Member

@DanielRosenwasser DanielRosenwasser commented Sep 29, 2020

It behaves like unique symbol cause I don't know how they should be compared about.

This is close to what we meant by "anti-any" - that it is a type that is assignable to/from nothing else.

Does the team discussion about the complex throw type?

We do like the idea about signaling a reason. It's kind of strange that this isn't localizable, but it's reasonable given that the code itself is written with some language in mind.

Should throw type supports conditional types and infer?

I'm not necessarily sure, do you mean being able to throw conditionals? Or support conditionals in a way that special-cases throw? I think that the case-by-case nature that you mentioned is something we're a little bit wary of. But it wouldn't be the first time we've added special cases in every type operation. @ahejlsberg might have thoughts here.

What should I change next?

I would hold off on any changes - we're not necessarily sure this is something we want in the language. If you're doing this mostly for the sake of experimentation, then it's really up to you.

@Jack-Works
Copy link
Contributor Author

@Jack-Works Jack-Works commented Sep 29, 2020

I'm not necessarily sure, do you mean being able to throw conditionals?

I'm meaning the ability of type MessageOfThrowType<T> = T extends throw infer T ? T : ''

I would hold off on any changes - we're not necessarily sure this is something we want in the language. If you're doing this mostly for the sake of experimentation, then it's really up to you.

😢 I'd really like to have this in the language, not an experimental forever, hope I can get a clear thought on how the TS team wants and let it get merged in future

@Artazor
Copy link
Contributor

@Artazor Artazor commented Nov 15, 2020

Just wanted to share my temporary solution based on anti-any pattern:

invalid.ts

const TypeErrorTag: unique symbol = Symbol('TypeErrorTag'); // don't export it!

export type invalid<Message> = {
  [x in keyof Message | typeof TypeErrorTag]: x extends keyof Message
    ? Message[x & keyof Message]
    : { readonly [TypeErrorTag]: unique symbol }; // anonymous unique symbol
};

extend.ts

How to use it in hypothetical extend library (usage example)

import { invalid } from './invalid';

export type Extend<
  Base extends object | null,
  Key extends string | number | symbol,
  Value
> = { [K in Exclude<keyof Base, Key>]: Base[K] } &
  {
    [K in Key]: Value extends Base[Key & keyof Base]
      ? Value
      : Base[Key & keyof Base] extends never
      ? Value
      : invalid<{
          message: 'Incorrectly overridden';
          key: Key;
          originalType: Base[Key & keyof Base];
          newType: Value;
        }>;
  };

export function extend<
  Base extends object | null,
  Key extends string | number | symbol,
  Value
>(base: Base, key: Key, value: Value): Extend<Base, Key, Value> {
  return Object.create(base, {
    [key]: { value, enumerable: true }
  });
}

Usage

What gets a final user when uses a library written with help of invalid<Message> type:

import { extend } from './extend';

const a = extend(null, "x", 10);           //   typeof a === {x: number}        
const b = extend(a, "y", true);            //   typeof b === {x: number, y: boolean}
const c = extend(b, "z", "Hello");         //   typeof c === {x: number, y: boolean, z: string}
const d = extend(c, "y", false as const);  //   typeof d === {x: number, y: false, z: string }
const e = extend(d, "z", 123);             //   typeof e === {x: number, y: false, z: {
                                           //        [TypeErrorTag]: unique symbol;
                                           //        message: "Incorrectly overridden";
                                           //        key: "z";
                                           //        originalType: string;
                                           //        newType: number;
                                           //   }}

let x = e.z + 1;  // in runtime x === 124
                  // yet in compile time it produces an error

let test: invalid<{}> = {}   // {} !== invalid<{}>

playground link

Comile-Time Error-1

Forgery protection

Users are unable to to access ErrorTypeTag and even if they will extract they will not be able to provide a correct value for the corresponding property. It's not existing at all. It's anonymous unique symbol.

Compile-Time Error-2

Drawbacks

Compile time error occurs lazily only when corresponding invalid type is compared (and thus instantiated) with something else. So if nobody used that type value, it will not produce any errors. As it was mentioned it would be cool to have an early errors. Yet it exposes type instantiation time, that was previously hidden.

Conclusion

While still waiting for eager throw types, this could be a nice temporary solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked issues

Successfully merging this pull request may close these issues.

You can’t perform that action at this time.