microsoft / TypeScript

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

[Proposal] Type assertion statement (type cast) at block-scope level #10421

Open yahiko00 opened 8 years ago

yahiko00 commented 8 years ago

This is a proposal in order to simplify the way we have to deal with type guards in TypeScript in order to enforce the type inference.

The use case is the following. Let us assume we have dozens (and dozens) of interfaces as the following:

Code

interface AARect {
    x: number; // top left corner
    y: number; // top left corner
    width: number;
    height: number;
}

interface AABox {
    x: number; // center
    y: number; // center
    halfDimX: number;
    halfDimY: number;
}

interface Circle {
    x: number; // center
    y: number; // center
    radius: number;
}

// And much more...

And we have a union type like this one:

type Geometry = AARect | AABox | Circle | // ... And much more

It is quite easy to discriminate a type from another with hasOwnProperty or the in keyword:

function processGeometry(obj: Geometry): void {
    if ("width" in obj) {
        let width = (obj as AARect).width;
        // ...
    }
    if ("halfDimX" in obj) {
        let halfDimX = (obj as AABox).halfDimX;
        // ...
    }
    else if ("radius" in obj) {
        let radius = (obj as Circle).radius;
        // ...
    }
    // And much more...
}

But, as we can see, this is quite burdensome when we need to manipulate obj inside each if block, since we need to type cast each time we use obj.

A first way to mitigate this issue would be to create an helper variable like this:

    if ("width" in obj) {
        let helpObj = obj as AARect;
        let width = helpObj.width;
        // ...
    }

But this is not really satisfying since it creates an artefact we will find in the emitted JavaScript file, which is here just for the sake of the type inference.

So another solution could be to use user-defined type guard functions:

function isAARect(obj: Geometry): obj is AARect {
    return "width" in obj;
}

function isAABox(obj: Geometry): obj is AABox {
    return "halfDimX" in obj;
}

function isCircle(obj: Geometry): obj is Circle {
    return "radius" in obj;
}

// And much more...

function processGeometry(obj: Geometry): void {
    if (isAARect(obj)) {
        let width = obj.width;
        // ...
    }
    if (isAABox(obj)) {
        let halfDimX = obj.halfDimX;
        // ...
    }
    else if (isCircle(obj)) {
        let radius = obj.radius;
        // ...
    }
    // And much more...
}

But again, I find this solution not really satisfying since it still creates persistent helpers functions just for the sake of the type inference and can be overkill for situations when we do not often need to perform type guards.

So, my proposal is to introduce a new syntax in order to force the type of an identifier at a block-scope level.

function processGeometry(obj: Geometry): void {
    if ("width" in obj) {
        assume obj is AARect;
        let width = obj.width;
        // ...
    }
    if ("halfDimX" in obj) {
        assume obj is AABox;
        let halfDimX = obj.halfDimX;
        // ...
    }
    else if ("radius" in obj) {
        assume obj is Circle;
        let radius = obj.radius;
        // ...
    }
    // And much more...
}

Above, the syntax assume <identifier> is <type> gives the information to the type inference that inside the block, following this annotation, <identifier> has to be considered as <type>. No need to type cast any more. Such a way has the advantage over the previous techniques not to generate any code in the emitted JavaScript. And in my opinion, it is less tedious than creating dedicated helper functions.

This syntax can be simplified or changed. For instance we could just have : <identifier> is <obj> without a new keyword assume, but I am unsure this would be compliant with the current grammar and design goals of the TypeScript team. Nevertheless, whatever any welcomed optimization, I think the general idea is relevant for making TypeScript clearer, less verbose in the source code and in the final JavaScript, and less tedious to write when we have to deal with union types.

shicks commented 5 years ago

Those are also unsatisfying in that they don't leave room for leaving the runtime assertion in place in debug mode. As I mentioned, we're really happy with our assert function, but it just doesn't translate.

Maybe something like assert(a instanceof A) || throw null; could become idiomatic, assuming TS could be taught how to back-infer something of the form assert(arg: any): arg is true, and then in production it always returns true so that the entire statement is removable, while in debug it throws instead of returning false, so that the throw null is again irrelevant. But I still don't really like it, and it doesn't address expressions (currently assert returns its argument, which is super convenient in e.g. use(assert(arg)), though we could probably get by without that).

bluelovers commented 5 years ago

still hope can have this

sometime there has use case make typescript is fail detect type

make need use many ts-ignore , as xxx

if we can use is, then can force fix it without fix every line

jdom commented 5 years ago

Are there any plans to support this?

MartinJohns commented 5 years ago

@jdom See #32695. :-)

MattiasBuelens commented 5 years ago

A possible solution for the original question with #32695 could look like this (I think):

function assumeType<T>(x: unknown): asserts x is T {
    return; // ¯\_(ツ)_/¯
}

function processGeometry(obj: Geometry): void {
    if ("width" in obj) {
        assumeType<AARect>(obj);
        let width = obj.width;
        // ...
    }
    if ("halfDimX" in obj) {
        assumeType<AABox>(obj);
        let halfDimX = obj.halfDimX;
        // ...
    }
    else if ("radius" in obj) {
        assumeType<Circle>(obj);
        let radius = obj.radius;
        // ...
    }
    // And much more...
}

The assumeType function is a no-op, so any decent minifier like terser or babel-minify should be able to remove those function calls. That should result in zero run-time overhead.

I have to admit it's not as pretty as the proposed assume x is T syntax. Still, I believe asserts is the more versatile solution and can be applied to other problems, so I'm happy with it. 🙂

yahiko00 commented 5 years ago

Let's hope the TS compiler won't create the function and its call as well.

dead-claudia commented 5 years ago

@yahiko00 It won't create it, but it will have to emit the call because the call could have side effects (like throwing or object mutation).

tuuling commented 4 years ago

@MattiasBuelens

function assumeType<T>(x: unknown): asserts x is T {
    return; // ¯\_(ツ)_/¯
}

The way I got it working was slightly different

declare function assumeType<T>(x: unknown): asserts x is T;

assumeType<MyType>(obj);

The real world example was to assert the types of redux actions my reducer was processing:

import * as Actions from './actions';

declare function assumeType<T extends (...args: any) => any>(x: unknown): asserts x is ReturnType<T>;

const mouseCords = (state = { x: 1, y: 1 }, action: AnyAction) => {
  switch (action.type) {
    case Actions.CHANGE_CORDS:
      assumeType<typeof Actions.changeCords>(action);
      return action.mouseCords;
    default:
      return state;
  }
};

It works but everyone has to roll their own implementation in the code for what looks like a pretty common use case.

shicks commented 3 years ago

Is it time to close this issue? Between discriminated unions and asserts functions, it seems like this is pretty well-covered, and it's doubtful enough of the original use case remains for any solution to be worth its complexity cost.

lostpebble commented 3 years ago

The fact that casting from one type to another still requires reassigning a variable or executing an actual function means that this isn't really solved for me.

Developers should have the power to control this kind of thing easily:

assume foo as TNuancedTypeThatIsBasicallyImpossibleToNarrowExactlyWithUnions;

// use the new type directly without type errors
foo.knownNewProperty();

The fact is, that entire line can just be removed with the TypeScript compilation process. It doesn't seem to add so much complexity that its too hard to implement? Its basically just an assertion without needing to call a function (add unnecesary computaitonal complexity- something we always want to strive for no matter how irrelevant it seems because in certain scenarios it can add up).

I agree that unions should be the thing to solve most of this stuff- but the fact is that often times, with large and complex unions this can be a huge challenge and sometimes doesn't work out how you'd expect because of optional properties or a union that's based off of something more nuanced than an exact property value.

All in all, I'd like TypeScript to work for me- not against me- when I know exactly what I'm doing. assume foo as * is a purposeful syntax- if its misused, like the any type and lots of other things in TypeScript can be misused, then that's on the developer.


I know that the current way we are expected to get this functionality (without needing an assert function or re-assign) is to do this:

(foo as TNuancedTypeThatIsBasicallyImpossibleToNarrowExactlyWithUnions).knownNewProperty();
const otherThing = (foo as TNuancedTypeThatIsBasicallyImpossibleToNarrowExactlyWithUnions).internalName();
runProcessOnFoo(foo as TNuancedTypeThatIsBasicallyImpossibleToNarrowExactlyWithUnions);

I don't think I need to elaborate on why this is terrible... But this is even worse as far as developers making mistakes goes- because now if we don't remember to type-cast foo every time its actually not the intended type.

MartinJohns commented 3 years ago

@lostpebble You can achieve almost that behavior with using assert functions:

function assumeAs<T>(val: any): asserts val is T { /* no implementation */ }

Using this function will cause the variable to be typed as typeof var & T.


edit: Missed that you mentioned this in your sentence. :-)

lostpebble commented 3 years ago

@MartinJohns okay, glad you saw. So yea, basically what you are suggesting is exactly what I'm saying is what's wrong with TypeScript at the moment.

(maybe can just remove our comments because its not adding to the discussion, we've gone over assert functions plenty in this thread)

shicks commented 3 years ago

@lostpebble Given that any reasonable optimizer will inline the empty function, there's absolutely zero overhead to calling assumeAs. You're proposing to add a feature, including new syntax no less, that can already be done for free. I'm sure it wouldn't be hard to implement, but the fact is that "all features start at -100", especially anything involving new syntax. In this situation, this one will never pay for itself.

lostpebble commented 3 years ago

Okay, so what about an unreasonable optimizer or none at all?

So you are saying the bottom line for type casting in TypeScript is to have useless functions or re-assignments in your code. TypeScript to me has always been about augmenting JavaScript with types for more safety- but here its forcing us to actually add unnecessary JavaScript to get the types we want.

You're proposing to add a feature, including new syntax no less, that can already be done for free.

The fact is its not "done for free"- it compiles down to an actual function call for something which is completely useless for your end JavaScript code. Whether a developer later uses a great optimizer down the line to get rid of that function is a different topic.

Basically, to achieve type-casting now with how you say we should do it, TypeScript expects our code to end up looking like this:

function assumeAs(thing) {
  return thing;
}

function regularCode(incoming) {
  assumeAs(incoming);
  incoming.runStuff();
}

I don't really get why we can't still aim to achieve this instead of being dismissive and conceding that it adds too much complexity for what it achieves. Internally TypeScript appears to have the ability to cast in a block with that assert function- why not just allow the same but with a special syntax.

(I'm not saying the syntax I mentioned has to be the final one either, just that it looks pretty nice and doesn't clash with current typescript keywords)

daprahamian commented 3 years ago

I think it is still worth pointing out that there are still some cases of type inference that AFAIK have not been solved yet in Typescript. Playground Example:

type Result = { key: string };

function NodeStyleCallback(err: number, result: null): void;
function NodeStyleCallback(err: null, result: Result): void;
function NodeStyleCallback(err: number|null, result: null|Result) {
    if (err == null) {
        // Based on call signatures, if err is null, then result must be a Result
        // object, but TS is unable to determine that. Type assertions could help
        // here.
        console.log(result.key); // Object is possibly 'null'.(2531)
    }
}

So assertions could really help, either the form of the assume x is _, or inline type guards based on if statements:

if (err == null) : result is Result {
  console.log(result.key);
}
shicks commented 3 years ago

@daprahamian It's a little tangential, but your example does have a workable (if not the prettiest) solution:

function NodeStyleCallback(err: number, result: null): void;
function NodeStyleCallback(err: null, result: Result): void;
function NodeStyleCallback(...args: [number, null]|[null, Result]) {
    if (args[0] == null) {
        // Based on call signatures, if err is null, then result must be a Result
        // object, but TS is unable to determine that. Type assertions could help
        // here.
        console.log(args[1].key); // No error
    }
}

(playground)

daprahamian commented 3 years ago

@shicks Thanks! I have seen that workaround, and it mostly works, but does technically break Function.prototype.length for the function. I think in the spirit of this feature request, the desire is for Typescript code to completely compile away. In general, having to write code differently not b/c it is bad practice, but b/c the type engine cannot understand it, is a very frustrating dev experience.

I think having a block-scoped assertion mechanism would really help to act as a stopgap for this and other edge cases in the typing system. As the typing system gets more and more sophisticated, it can be used less and less.

Ayub-Begimkulov commented 3 years ago

Is it time to close this issue? Between discriminated unions and asserts functions, it seems like this is pretty well-covered, and it's doubtful enough of the original use case remains for any solution to be worth its complexity cost.

But what about the case with functions that mutate the properties of an exciting object? Right now you can do something like this:

function setB<T extends Record<string, any>>(obj: T): asserts obj is T & { b: number[] } {
  (obj as any).b = [1, 2, 3]
} 

But the problem is that if the object already property b with type string, then the type of obj.b will be intersection of types string and number[]:

const myObject = { b: 'hello world' };

setB(myObject);

type BType = typeof myObject.b // string & number[]

Which could lead to unnecessary bugs. I tried to create my function like this:

type SetB<T extends Record<string, any>> = {
  [K in (keyof T | 'b')]: K extends 'b' ? number[] :T[K] 
}

function setB<T extends Record<string, any>>(obj: T): asserts obj is SetB<T> {
  (obj as any).b = [1, 2, 3]
} 

But it compiles with type error:

A type predicate's type must be assignable to its parameter's type.

I'm not trying to say that this syntax must be added to the language. But, unfortunately, right now I don't see any way to correctly type functions that mutate objects, and in my opinion, something needs to be done about it.

EnderShadow8 commented 3 years ago

I found an absurd workaround for this issue. For some reason, using a @ts-ignore before a declare still declares the variable type, but suppresses the warning that Modifiers cannot appear here.

In fact, this could be a possible syntax if this suggestion were to be implemented, since it doesn't create any new keywords.

interface A {
    x: number;
    z: number;
}

interface B {
    y: number;
    z: number;
}

interface C {
    x: number;
    y: number;
}

type Union = A | B | C;

function check(obj: Union): void {
    if ("x" in obj && "z" in obj) {
        // @ts-ignore
        declare let obj: A

        // obj is now treated as an instance of type A
    }
    else if ("y" in obj && "z" in obj) {
        // @ts-ignore
        declare let obj: B

        // obj is now treated as an instance of type B
    }
    else if ("x" in obj && "y" in obj) {
        // @ts-ignore
        declare let obj: C

        // obj is now treated as an instance of type C
    }
}

EDIT:

This only works in a new scope, for example inside a block. Usually, since this is useful to augment type narrowing, this is not an issue. However, casting variables such as function arguments using let produces a duplicate declaration error, and using var simply fails silently.

mindplay-dk commented 3 years ago

@EnderShadow8 this is interesting 🙂

I did some tests to see if I could break it, but it seems to work - my main worry was the redeclared types would somehow leak to the .d.ts file, but that does not appear to be the case, and IDE support looks sound as well.

I couldn't find any gotchas - is this really as simple as just lifting the restriction and allowing what already works? 🤷‍♂️

It should probably still have a dedicated syntax though, since let seems to imply we're actually declaring a new variable with the same name, shadowing a variable in the parent block scope, which isn't really what's happening. Although, in practical terms (in terms of typing) I suppose the effects of doing that would be exactly the same.

If nothing else, this makes for a pretty nifty workaround. 😃👍

EDIT: the only caveat I can find is the fact that the new symbol is just that: a new symbol, shadowing the symbol on the parent block scope - which means, your inner obj is an entirely different symbol, disconnected from obj in the parent scope, as you can see if you use "find references" ... as you can see in the example here, there's no relationship with the declaration on line 18:

image

So the risk of doing this, is it will break if you rename or remove the parent symbol - and emit code that fails.

frank-weindel commented 2 years ago

Just wanted to drop another use case for this that I documented on StackOverflow.

I came up with a similar solution as @MattiasBuelens's assumeType<T> assertion. But it still would be nice not to have to rely on tree shaking/dead-code elimination to achieve this without runtime consequences. Especially since that elimination could be prevented by the presumptive possibility of side effects in some cases.

shicks commented 2 years ago

FWIW, I'd be surprised if even the most basic optimizer can't recognize and eliminate calls to a no-op function:

$ echo 'function noop(){} console.log(1); noop(); console.log(2);' | npx esbuild --format=iife --minify
(()=>{console.log(1);console.log(2);})();
frank-weindel commented 2 years ago

@shicks No but you can easily end up with a case where maybe the function call is eliminated but the argument is not due to the possibility of side effects.

$ echo 'class MyClass { prop = 123 } const myClass = new MyClass(); function noop(arg){} console.log(1); noop(myClass.prop); console.log(2);' | npx esbuild --format=iife --minify
(()=>{class o{prop=123}const s=new o;console.log(1);s.prop;console.log(2);})();
mnpenner commented 2 years ago

I just use babel-plugin-unassert. That both informs TS and gives me runtime assertions in dev but removes them for prod.

davidglezz commented 1 year ago

I think that this issue can be closed because Typescript already narrows the type correctly (at least since version 3.3, which is the last one that we can test in the playground).

image

Playground

vegerot commented 1 year ago

I love this idea. This thread is really long but what're the current blockers?

jacekkopecky commented 1 year ago

With the new satisfies keyboard, I'd love to be able to write code like this:

interface AOptional {
  a?: number,
  // ...
}

interface ARequired {
  a: number,
  newProp?: string,
  // ...
}

function convertToARequiredInPlace(obj: AOptional): ARequired {
  obj.a ??= 0;
  obj satisfies ARequired; // this currently fails but the compiler could see that the line above fixes that

  // then TS could start treating obj as ARequired and these would both be allowed
  obj.newProp = "foo"; 
  return obj;
}
tjzel commented 9 months ago

Why hasn't this been included for so many years? Reanimated in React-Native really could use it - we have to face multiple architectures, platforms and it would be nice if after checking what platform we are on we could inline type guard some parameters to be platform specific. Since performance is key for us even a couple type guard calls can be too expensive and multiple type assertions are just cluttering the code.

shicks commented 9 months ago

Why hasn't this been included for so many years? Reanimated in React-Native really could use it - we have to face multiple architectures, platforms and it would be nice if after checking what platform we are on we could inline type guard some parameters to be platform specific. Since performance is key for us even a couple type guard calls can be too expensive and multiple type assertions are just cluttering the code.

If you write a no-op type guard, you shouldn't see any performance regression. Nearly every VM will do JIT optimization to inline the empty function call into the calling functions' bytecode. The only exception I'm aware of is iOS native, which doesn't allow it, but in that case you're already bundling, and every bundler can (and will) also inline empty functions in production builds. Such an empty type guard isn't particularly "safe" (since it's not actually doing any checking at runtime), but depending on how you type it, it's no less safe than an ordinary as T type assertion that you'd write without it.

See https://jsbench.me/00ls2rex1g/1

dead-claudia commented 9 months ago

Also, you can get 90% of the way there with value = value as T (it works most of the time but not all of the time), and that's something that can be statically dropped without even needing to do lexical analysis first.

tjzel commented 9 months ago

@shicks That's a very useful concept, but imo makes the code less readable.

variable is Type;

seems to be much more informative than

noopAssertJustToConfirmScopeType(variable);

But of course the function can be named better etc. and some guidelines can be added, so that's just an opinion.

Unfortunately, we cannot exactly rely on JIT optimizations since what we actually do is copying JS functions in between JS runtimes via their code (as a string).

I know it sounds dumb, but currently it's the only option we have, since we must do it in runtime. We are looking into the possibility of having this code copied before the runtime is started, so we could actually have those JIT optimizations (along many other things) included, but at the moment we aren't exactly sure if it's feasible and it requires effort not only from us but also from folks from React Native.

Maybe this comes from my misunderstanding of how TypeScript should be used - I always considered it to be an augmentation of JavaScript - that means, if I have a JS function that is dynamically type safe, I can make its TypeScript counterpart statically type-safe, compile it and get exactly the same code as the original JS function. If that's not the mission of TypeScript, I'm completely fine with that and the feature proposed here isn't necessary.

@dead-claudia I definitely agree that I can get away with simple type assertions but I'm talking here more about code readability.

From time to time we have some really "dense" core functions, some algorithms that are very concise and have to take into account various platforms. Once you know the code there's no problem skipping as Type when you read it, but when some new member of the team gets to work with it and he sees multiple type assertions everywhere it can make the process of understanding the code more difficult.

You might argue that in this case the function is poorly written in TypeScript - and I agree with you, back in the day, the code was purely JS and rewriting it into TS wasn't the simplest of tasks. I try to tackle down such functions and rewrite them to be of equivalent performance and behavior but with better types. However, since they are core functions, it requires a lot of attention from the whole team to make sure there aren't any regressions involved in those type of refactors, since there are many little details that could be overlooked. Therefore such inline type guards would be a good QoL addition.

lostpebble commented 9 months ago

@tjzel I agree 100% that something like this in the language would be amazing- our comments are almost 1 to 1 (see earlier in the thread).

I've found that this is probably the simplest way to do this as of now:

function cast<T>(it: any): T {
  return it;
}

function castTest() {
  const testObject = {
    color: "red",
  };

  // asString is now a string type (obviously bad though)
  const asString = cast<string>(testObject);
}

The syntax isn't so bad- and feels natural enough.

const color = cast<Color>(someColorThing);

But yea, having to do this could be dangerous overall and remove some of the type safety that TypeScript gives to us. But there definitely are places where this comes in handy (not every project is greenfield).

jdom commented 9 months ago

@lostpebble your last workaround doesn't really address this. The whole purpose of this issue is to make an existing variable be inferred as a different type, not create a new variable nor make function calls that impact runtime. Otherwise the simplest is:

const color = someColorThing as Color;
const asString = testObject as unknown as string;

But again, the whole purpose is to avoid that runtime change entirely.

oscarhermoso commented 9 months ago

Just sharing another scenario where this would be super helpful - receiving an object as a parameter, extending it, and then returning it as the new type, without assigning any new variables.

Given a scenario of building a JSON response, this is currently my code:

export async function assign_file_url<F extends FileProperties>(file: F) {
    if (file.kind === 'file') {
        // @ts-expect-error: 'url' does not exist, type is cast at return
        file.url = await storage.bucket(file.bucketId).presignGetUrl(file.publicId);
    } else {
        // @ts-expect-error: 'url' does not exist, type is cast at return
        file.url = null;
    }

    return file as F & { url: string | null };
}

And this could be the code after an "assume" keyword is introduced.

export async function assign_file_url<F extends FileProperties>(file: F) {
    assume file as F & { url: string | null };

    if (file.kind === 'file') {
        file.url = await storage.bucket(file.bucketId).presignGetUrl(file.publicId);
    } else {
        file.url = null;
    }

    return file;
}
Anatoly03 commented 5 months ago

@oscarhermoso I don't think a new keyword is needed, as the keyword satisfies could have a good use here, by being declared as a variable in block scope, it could be treated as a typehint:

// Feedback, not valid
const value: number | Object ...

if (Number.isNumber(value)) {
    value satisfies number
    console.log(2 + value)
} else {
    console.log('value is an object')
}
snarbies commented 5 months ago

value satisfies number is already valid code with a meaning contradictory to what you're suggesting.

interface Circle {
    radius: number;
    x: number;
    y: number;
}

const circlePlus = 
    {x: 0, y: 0, radius: 1, color: "red"} as const;

 // Errors if you remove x, y, or radius properties
circlePlus satisfies Circle;

The satisfies operator validates that a value satisfies a type. The point of type assertions is that validation is elided because you're telling the compiler you already know better.

mindplay-dk commented 5 months ago

Yeah, this seems more like a declaration as I've suggested before.

This is already valid:

declare let value: number;

function lol() {
  const n = value + 1;
}

My intuition was always that declarations would "just work" in scopes:

function lol() {
  declare let value: number;
  const n = value + 1;
}

It's already syntax that can be parsed - it just doesn't mean anything.

(an earlier request had a nice approach to the syntax, and avoided shadowing, as mentioned in my previous comment.)

mbleigh commented 3 months ago

I would love to see inline/block-scope type guards (I didn't know the right words for this stuff when I posted on Twitter). This is probably the thing I run into in TypeScript that is most annoying - automatic type narrowing is really cool but has many obvious limits, and type guards having to be functions makes them too cumbersome for common use cases.

In my ideal world there would be a mechanism to accomplish two things simultaneously:

  1. Treat a value as any for the purpose of narrowing its type
  2. Declare the type that it narrows to

This is the kind of code I don't like:

if ((foo as any)?.someProperty) {
  const fooCast = foo as ThingWithSomeProperty;
  // ... code here
}

What I'd prefer to write is something like:

if (foo?.someProperty): foo is ThingWithSomeProperty {
  // ... code here
}