Skip to content

User-defined type assertions #17760

Closed
Closed

Description

Proposal: support user-defined type assertions (keyword as) in addition to user-defined type guards (keyword is).

A user-defined type assertion is a function with a return type expressed as in the following example (continuing the handbook example in "Advanced Types").

function assertIsFish(pet: Fish | Bird): pet as Fish {
    if (!isFish(pet))
        throw new Error("birds are not supported");
}

assertIsFish(pet);

pet.swim();

:pet as fish means that, if the function assertIsFish returns without throwing, pet is automatically asserted to be of type Fish.

Use-cases:

  1. Validators: user-defined type assertions make it possible to encapsulate in functions the type narrowing that is currently available through control flow analysis. They also allow types that better describe the behavior of many existing libraries, including: node asserts, data validators that throw detailed errors, many test frameworks.

  2. Polyfills: enables describing functions that modify objects by adding new properties.

addName<T>(obj: T): obj as T & { name: string } {
    obj.name = "Mike";
}

const obj = {};
addName(obj);

obj.name.indexOf("i");

Validator examples:

simple undefined checking

function notUndefined<T>(obj: T | undefined): obj as T {
    if (obj === undefined)
        throw new Error("invalid object");
}

const myObj: string | undefined = {} as any;

// [ts] Object is possibly 'undefined'.
myObj.indexOf("");

notUndefined(myObj);

// no errors
myObj.indexOf("");

joi-like validation currently requires implementing the schema description of an object for runtime checking and copying the schema to an interface to have static type checking while the static type could be inferred by the implementation of the schema

interface Schema<T> {
    validate(obj: any): obj is T;
}

function attemptValidation<T>(obj: any, schema: Schema<T>): obj as T {
    if (!schema.validate(obj))
        throw new Error("very detailed object validation error");
}

// complex schema implements T and T is inferred by the schema construction
// this is already possible
const complexSchema = Joi.object({
    myField: Joi.date().iso()
});

attemptValidation(objFromTheRealWorld, complexSchema);

const day = objFromTheReadWorld.myField.getDay();

Activity

  1. jcalz commented on Aug 14, 2017

    @jcalz
    Contributor

    I like the idea. I've needed something like this before, and the way I've done it is by immediately throwing if the relevant type guard returns false. So you can have the somewhat cumbersome:

    function unsafeNarrow<T>(x: any): x is T {
      return true;
    }
    const obj = {height: 42};  // obj is {height: number}
    if (!unsafeNarrow<{ name: string }>(obj)) throw null;
    // obj is {height: number} & {name: string} from here on
    obj.name = 'mike';  // no error

    It would be nice to have something less ugly. Not sure if the x as T syntax is the way to go? Maybe we can continue to use x is T but the control flow analysis can tell that the returned value is always true and therefore any call to the type guard does automatic narrowing? So something like true & (x is T), if it compiled, would be a possibility.

  2. maghis commented on Aug 14, 2017

    @maghis
    Author

    Joe Calzaretta (@jcalz) I agree, I'm open to any syntax, that was just a suggestion as a starting point.

    Another really annoying thing is in tests, where you first assert that an object is truthy and then you have to explicitly check for it with an if or a type assertion before you can assert on values of the object.

    // obj comes from some test invocation
    assert(obj);
    // we already know that obj is not undefined but
    if (obj) {
        assert.equal(obj.field, "foo");
    }
  3. mhegazy commented on Aug 22, 2017

    @mhegazy
    Contributor

    also related to #10421

  4. TazmanianD commented on Oct 12, 2017

    @TazmanianD

    I was just looking for something like this as well. I agree with Joe Calzaretta (@jcalz) that it seems like the existing syntax should continue to work and it's just the compiler code analysis can take into account that the failure in the function means the code throws and exits and a non-failure confirms the assertion.

  5. maghis commented on Oct 19, 2017

    @maghis
    Author

    TazmanianD I agree, we don't necessarily need new syntax, it was just nice to know by just looking at the function signature that I don't have to check for a return value.

  6. mitchellwills commented on Oct 22, 2017

    @mitchellwills

    I was just looking for something like this as well. I don't think you can use the same x is T return value syntax because you won't always be able to do control flow analysis on the called function, such as if it's referencing a function declaration.

    I'm also not sure that the original return value x as T syntax works either since it doesn't really have any thing to do with the return value of the function, just that the function actually returned. It's more of a postcondition. Having a syntax that goes in the return value slot would prohibit having an actual return value from the function. For example:

    // where do I write an explicit return type?
    function initializeAndGetId<T>(obj: T): obj as T&{id: number} { 
      (obj as any).id = generateId();
      return obj.id;
    }
    
    const value = {};
    const id = initializeAndGetId(value);
    // this should compile since value should now be assumed to have the type {id: number}
    assert(id === value.id);

    Unfortunately, I don't really have any better suggestions.

  7. MicahZoltu commented on May 27, 2018

    @MicahZoltu

    There are a number of ES built-in functions that would greatly benefit from this like Number.isFinite(). Currently the following will not work:

    function method(param: any) {
        const myNumber: number = Number.isFinite(param) ? param : 5
    }

    Instead, you must do something like:

    function method(param: any) {
        const myNumber: number = (typeof param === 'number' && Number.isFinite(param)) ? param : 5
    }
  8. maghis commented on Jun 5, 2018

    @maghis
    Author

    Micah Zoltu (@MicahZoltu) In that case it's because param is already defined as number in the type definitions.
    You could achieve the same results with the existing syntax if the function was defined as (param: any) => param is number.

  9. MicahZoltu commented on Jun 5, 2018

    @MicahZoltu

    Adding param is number was my hope, but apparently it doesn't actually work in this case as described here: #24436 (comment)

  10. maghis commented on Jun 6, 2018

    @maghis
    Author

    Micah Zoltu (@MicahZoltu) that makes sense, I didn't think about the side effects.

  11. aciccarello commented on Jan 28, 2019

    @aciccarello

    I'm also running into a similar situation to Massimo Andreasi Bassi (@maghis) in tests as #17760 (comment). I'd like for assert.isDefined(param) to be able to be typed the same as an if that throws an error.

    let callbackParam: string | undefined;
    doesThisCallTheCallback((p) => callbackParam = p);
    
    if (callbackParam == null) { // Want this to be in assert.isDefined()
      throw new Error("TypeScript knows callbackParam is string if this hasn't thrown yet");
    }
    assert.isDefined(callbackParam); // throws if undefined
    assert.equals(callbackParam.split(' ').length, 5); // Should know callbackParam is a string here
  12. RyanCavanaugh commented on Jan 28, 2019

    @RyanCavanaugh
    Member

    Anthony Ciccarello (@aciccarello) FYI that behavior is tracked at #10421 / #8655

  13. ikokostya commented on Jan 29, 2019

    @ikokostya
    Contributor

    Ryan Cavanaugh (@RyanCavanaugh) Issue #8655 was closed without good explanation. Many questions in the thread are still without answers. Suggested throw expressions doesn't solve all cases, e.g. process.exit() in Node.js.

  14. jwalton commented on Mar 29, 2019

    @jwalton

    Testing with chai is another obvious place where this would be nice:

    import { expect } from 'chai';
    
    it('should do the thing', function() {
      const result = doTheThing();
      expect(result).to.exist; 
      expect(result!.foo).to.equal(7); // I don't want to type this !.  :P
    });

    I feel like there should be some sort of "condtional type" syntax that could be used here. Something like:

    function assertNotUndefined(x: string | undefined) : x is undefined ? true : never {
        if(x === undefined) {
            throw new Error('undefined');
        }
        return true;
    }
  15. mheiber commented on Jun 4, 2019

    @mheiber
    Contributor

    In order to meet our use case, user-defined type assertions would have to be intersectable with normal return values. In the below, I use this syntax instead of as T: Ensures<T>.

    For example, suppose that you have a UI framework where children only have .layout property if they have been added to a parent.

    It would be really helpful if we could plug in to the same sort of flow analysis used by type guards.

    Example of desired behavior using Ensures:

    // adding a button to a container gives it a 'layout' property
    const button = new Button();
    button.layout; // error because we don't know if it is added to a container yet
    if (shouldAdd) {
        container.addChild(button);
        button.layout; // ok because of the `Ensures` type (defined below)
    }
    else {
        button.layout; // error because control flow didn't hit anything that ensures `button.layout` defined
    }
    
    // definitions
    class Component { /* ... */ }
    class Container extends Component {
        children: Component[] = [];
        addChild<T extends Component>(child: T): this & Ensures<T is T & { layout: Layout }> {
            this.children.push(child)
        }
    }
  16. ethanresnick commented on Feb 24, 2020

    @ethanresnick
    Contributor

    This should probably be closed now that #32695 has been merged (although that PR still doesn't cover the request in the comment immediately above, to be able to return a normal value and assert with the same function).

  17. maghis commented on Mar 27, 2020

    @maghis
    Author

    I think so, asserts covers pretty much all the listed use cases.
    Anyone against me closing?

  18. DanielRosenwasser commented on Apr 28, 2022

    @DanielRosenwasser
    Member

    Looks like this was fixed in #32695

  19. added
    FixedA PR has been merged for this issue
    and removed
    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this feature
    on Apr 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    FixedA PR has been merged for this issueSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions