Skip to content

Operator to ensure an expression is contextually typed by, and satisfies, some type #7481

Description

Sometimes it's necessary (e.g. for guiding type inference, for ensuring sub-expression conforms to an interface, or for clarity) to change the static type of an expression. Currently TypeScript has the as (aka <>) operator for that, but it's dangerous, as it also allows down-casting. It would be nice if there was another operator for implicit conversions only (type compatibility). I think this operator should be recommended in most cases instead of as.

This operator can be implemented as a generic function, but as it shouldn't have any run-time effect, it would be better if it was incorporated into the language.

function asType<T>(value: T) {
  return value;
};

EDIT: Due to parameter bivariance, this function is not equivalent to the proposed operator, because asType allows downcasts too.

Activity

  1. RyanCavanaugh commented on Mar 11, 2016

    @RyanCavanaugh
    Member

    Can you post a few examples of how you'd like this to work so we can understand the use cases?

  2. magnushiie commented on Mar 12, 2016

    @magnushiie
    ContributorAuthor

    One example very close to the real-world need (I'm trying to get react and react-redux typings to correctly represent the required/provided Props):

    import { Component } from "react";
    import { connect } from "react-redux";
    
    // the proposed operator, implemented as a generic function
    function asType<T>(value: T) {
      return value;
    };
    
    // in real life, imported from another (actions) module
    function selectSomething(id: string): Promise<void> {
      // ...
      return null;
    }
    
    interface MyComponentActions {
      selectSomething(id: string): void;
    }
    
    class MyComponent extends Component<MyComponentActions, void> {
      render() {
        return null;
      }
    }
    
    // I've changed the connect() typing from DefinitelyTyped to the following:
    // export function connect<P, A>(mapStateToProps?: MapStateToProps,
    //                            mapDispatchToProps?: MapDispatchToPropsFunction|A,
    //                            mergeProps?: MergeProps,
    //                            options?: Options): ComponentConstructDecorator<P & A>;
    
    // fails with "Argument of type 'typeof MyComponent' not assignable" because of 
    // void/Promise<void> mismatch - type inference needs help to upcast the expression
    // to the right interface so it matches MyComponent
    export const ConnectedPlain = connect(undefined, {
      selectSomething,
    })(MyComponent);
    
    // erronously accepted, the intention was to provide all required actions
    export const ConnectedAs = connect(undefined, {
    } as MyComponentActions)(MyComponent);
    
    // verbose, namespace pollution
    const actions: MyComponentActions = {
      selectSomething,
    };
    export const ConnectedVariable = connect(undefined, actions)(MyComponent);
    
    // using asType<T>(), a bit verbose, runtime overhead, but otherwise correctly verifies the
    // expression is compatible with the type
    export const ConnectedAsType = connect(undefined, asType<MyComponentActions>({
      selectSomething,
    }))(MyComponent);
    
    // using the proposed operator, equivalent to asType, does not compile yet
    export const ConnectedOperator = connect(undefined, {
      selectSomething,
    } is MyComponentActions)(MyComponent);

    I've called the proposed operator in the last snippet is.

    The other kind of scenario is complex expressions where it's not immediately obvious what the type of the expression is and helps the reader understand the code, and the writer to get better error messages by validating the subexpression types individually. This is especially useful in cases of functional arrow function expressions.

    A somewhat contrived example (using the tentative is operator again), where it's not immediately obvious what the result of getWork is, especially when it's a generic function where the result type depends on the argument type:

    const incompleteTasks = (tasks: Task[]) => tasks.filter(task => !(getWork(task.currentAssignment) is Todo).isComplete);
  3. DanielRosenwasser commented on Mar 12, 2016

    @DanielRosenwasser
    Member

    I ran into something similar when I was patching up code in DefinitelyTyped - to get around checking for excess object literal assignment, you have to assert its type, but that can be a little extreme in some circumstances, and hides potential issues you might run into during a refactoring.

    There are also scenarios where I want to "bless" an expression with a contextual type, but I don't want a full blown type assertion for the reasons listed above. For instance, if a library defines a type alias for its callback type, I want to contextually type my callback, but I _don't_ want to use a type assertion.

    In other words, a type assertion is for saying "I know what I'm going to do, leave me a alone." This is more for "I'm pretty sure this should be okay, but please back me up on this TypeScript".

  4. RyanCavanaugh commented on Mar 14, 2016

    @RyanCavanaugh
    Member

    Sounds a lot like #2876?

  5. magnushiie commented on Mar 14, 2016

    @magnushiie
    ContributorAuthor

    If I understand #2876 correctly, it's still a downcast (i.e. bypassing type safety). What I was proposing here is an upcast (i.e. guaranteed to succeed at runtime or results in compile time error). Also, while <?> seems a bit like magic, the is operator is as straightforward as assigning to a variable with a defined type or passing an argument to a function with a parameter that has a defined type.

    I think the best example of this operator exists in the Coq language:

    Definition id {T} (x: T) := x. 
    Definition id_nat x := id (x : nat).
    Check id_nat.
    id_nat
         : nat -> nat

    Here, the expression x : nat is a type cast, where Coq's type cast is not dynamic but static (and mostly used in generic scenarios, like the ones I mentioned above) - here it means id_nat's argument type is restricted to be a nat.

  6. chilversc commented on Jun 9, 2016

    @chilversc

    Another case for this is when returning an object literal from a function that has a type union for it's return type such as Promise.then.

    interface FeatureCollection {
      type: 'FeatureCollection'
      features: any[];
    }
    
    fetch(data)
      .then(response => response.json())
      .then(results => ({ type: 'FeatureCollection', features: results }));

    This gets quite tricky for intellisense in VS because the return type from then is PromiseLike<T> | T. Casting allows intellisense to work, but as mentioned it can hide errors due to missing members.

    Also the error messages when the return value is invalid are quite obtuse because they refer to the union type. Knowing the intended type would allow the compiler to produce a more specific error.

  7. magnushiie commented on Sep 14, 2016

    @magnushiie
    ContributorAuthor

    Chris Chilvers (@chilversc) I'm not sure how an upcast can help with your example. Could you show how it would be used, using the above asType function (which is the equivalent to the operator I'm proposing). Note that due to parameter bivariance, the current compiler would not always give an error on invalid cast.

  8. chilversc commented on Sep 18, 2016

    @chilversc

    Odd, I thought I had a case where an assignment such as let x: Foo = {...}; would show a compile error while a cast such as let x = <Foo> {...}; would not.

    The cast was required to get the object literal to behave correctly as in this case:

    interface Foo {
        type: 'Foo',
        id: number;
    }
    let foo: Foo = { type: 'Foo', id: 5 };
    let ids = [1, 2, 3];
    
    //Error TS2322 Type '{ type: string; id: number; }[]' is not assignable to type 'Foo[]'.
    //Type '{ type: string; id: number; }' is not assignable to type 'Foo'.
    //Types of property 'type' are incompatible.
    //Type 'string' is not assignable to type '"Foo"'.
    let foosWithError: Foo[] = ids.map(id => ({ type: 'Foo', id: id }));
    
    let foosNoErrorCast: Foo[] = ids.map(id => ({ type: 'Foo', id: id } as Foo));
    let foosNoErrorAssignment: Foo[] = ids.map(id => {
        let f: Foo = {type: 'Foo', id: id};
        return f;
    });
  9. normalser commented on Sep 28, 2016

    @normalser

    Could we just use is the same way as as ?

    interface A {
       a: string
    }
    
    let b = {a: 'test'} as A // type: A, OK
    let c = {a: 'test', b:'test'} as A // type: A, OK
    let d = {a: 'test'} is A // type: A, OK
    let e = {a: 'test', b:'test'} is A // error, b does not exist in A
  10. aluanhaddad commented on Dec 29, 2016

    @aluanhaddad
    Contributor

    @wallverb that is really clever and really intuitive. Interestingly, it also provides a manifest way of describing the difference between the assignability between fresh object literals target typed by an argument vs existing objects that conform to the type of that argument.

  11. dead-claudia commented on Feb 14, 2017

    @dead-claudia

    I like this idea of effectively a static type assertion.

  12. magnushiie commented on Mar 29, 2017

    @magnushiie
    ContributorAuthor

    normalser your example about let e = {a: 'test', b:'test'} is A is to me still an up-cast and should succeed as proposed by this issue. I think your expectation is more towards #12936. Although if A is an exact type (as proposed in #12936), the is operator would error as well.

  13. 80 remaining items

  14. simonbuchan commented on Jan 20, 2022

    @simonbuchan

    Kasper Peulen (@kasperpeulen) thanks for the code and examples! Glad to see it works out, I wasn't on a computer and couldn't figure out what the satisfies function would look like.

  15. shicks commented on Feb 1, 2022

    @shicks
    Contributor

    I'd like to interject a slight tangent (pessimistically, scope creep?) that I haven't seen pointed out yet, but I think would be a good use case for any keyword introduced for these static checks. Specifically, some generic constraints are impossible to express due to circular references, and unlike top-level variable/expression type constraints, there's no other place to even put a dead assignment to get the check.

    This has come up for me a number of times as a framework author wanting to type my APIs as tightly as possible to prevent accidental misuse. Most recently, I was trying to write an "isEnum" type guard (see also #30611):

    function isEnum<T extends Enum<T>>(enumContainer: T): (arg: unknown) => arg is T[keyof T] { ... }
    type Enum<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;

    Unfortunately, because of the [P in E] index required to work around #26362, TypeScript sees a circular reference and can't handle the T extends Enum<T>, but a human can look at this and see that this is not so much a type bound (that needs to be resolved in order to typecheck the body of the function), but rather a type constraint that only matters for checking callsites. This seems like an excellent use case for function isEnum<T satisfies Enum<T>> or function isEnum<T implements Enum<T>>, which would check the constraint for callers, but not have a problem with circularity.

    The reason this is a good fit is that there's no other good place to do this check, due to how TypeScript does its type checking. In C++, you can write a static_assert in the body of the function, and since it re-typechecks each generic function with every new instantiation, it works. But TypeScript uses the template bounds to type check the function body once, and from then on callers are reduced to just checking the template bounds - so this allows more extensive verification that the types passed into the templates are actually reasonable.

    FWIW, my current workaround is pretty ugly and doesn't work universally. It essentially boils down to

    type NotAnEnum = {__expected_an_enum_type_but_got__: T};
    type EnumGuard<T> = ... ? (arg: unknown) => arg is T[keyof T] : NotAnEnum<T>;
    function isEnum<T>(enumContainer: T): EnumGuard<T> { ... }

    This gets the job done, but as folks upthread have pointed out, it moves the error from the construction to the usage - because the isEnum function typechecks no matter what, it just returns an unusable value. This also relies on there being usable and unusable values: if the function returned void, for instance, this wouldn't work so well.

    EDIT: added syntax highlighting

  16. kasperpeulen commented on Feb 4, 2022

    @kasperpeulen

    One other reason in favour of allowing satisfies to "assume at least this shape if (and only if) it's safe to do so".

    When working with a lot of tuples, I often have the following problem:

    declare function getTuple(): [number, number];
    declare function calculate(tuple: [number, number]): number;
    declare function memo<T>(factory: () => T): T;
    
    function foo() {
      const [a, b] = getTuple();
      const tuple = memo(() => [b, a * 2]);
      // TS2345: Argument of type 'number[]' is not assignable to parameter of type '[number, number]'.
      // Target requires 2 element(s) but source may have fewer.
      return calculate(tuple); 
    }

    I think most people would first as const, but that gives another problem:

    function foo() {
      const [a, b] = getTuple();
      const tuple = memo(() => [b, a * 2] as const);
      // TS2345: Argument of type 'readonly [number, number]' is not assignable to parameter of type '[number, number]'.   
      // The type 'readonly [number, number]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'.
      return calculate(tuple); 
    }

    So now, what do you do? You have two options:

    • one is to change the parameter of calculate to be a readonly [number, number], which is fine, but also feels a bit arbitrary if you are not using readonly types in the rest of the project, and sometimes it means that many functions need to be refactored to readonly.

    • the other option is, to not use as const, but use as [number, number]. This seems like a solid solution, but it is actually quite dangerous, because what if now a refactor made getTuple to return declare function getTuple(): [number, number | undefined]; You may think that you could safely do this refactor in strict typescript, while my code stays sound, but no!

    declare function getTuple(): [number, number | undefined];
    declare function memo<T>(factory: () => T): T;
    declare function calculate(tuple: [number, number]): number;
    
    function foo() {
      const [a, b] = getTuple();
      // No error, but this code is not sounds anymore, silently converting number | undefined to a number
      const tuple = memo(() => [b, a * 2] as [number, number]);
      return calculate(tuple);
    }

    With satisfies, this could be done in a sound way:

    declare function getTuple(): [number, number | undefined];
    declare function memo<T>(factory: () => T): T;
    declare function calculate(tuple: [number, number]): number;
    
    function foo() {
      const [a, b] = getTuple();
      // This will not compile. The type need to be changed to [number | undefined, number] or the null value must be handled.
      const tuple = memo(() => [b, a * 2] satisfies [number, number]);
      return calculate(tuple);
    }
  17. mbrowne commented on Feb 7, 2022

    @mbrowne

    Here's another use case to consider:

    import type { RequestHandler } from 'express'
    
    interface RouteHandlers {
        login: RequestHandler
        logout: RequestHandler
    }
    
    export const routeHandlers: RouteHandlers = {
        login(req, res) {
            ...
        },
    
        logout(req, res, next) {
            ...
        }
    }

    satisfies would certainly make this more DRY:

    export const routeHandlers = {
        login: ((req, res) => satisfies<RequestHandler>({
            ...
        })),
    
        logout: ((req, res) => satisfies<RequestHandler>({
            ...
        })),
    }

    ...although in this case I can envision some syntax that would be even more concise, given that every value in the object should be a function of type RequestHandler. Obviously there are more important things than just being concise, just wanted to share the use case.

  18. cefn commented on Feb 10, 2022

    @cefn

    To summarise what I recorded in the other issue (before I was helpfully referred to this feature discussion) and responding to Ryan Cavanaugh (@RyanCavanaugh) ....

    What is the type of 'x'

    What I expected from a like operator - it would do nothing at all to the types of any existing language construction, but it WOULD raise compiler errors if the item cannot be known to satisfy the definition.

    So for cases where the type IS satisfied it's a passthrough, and nobody has to reason about it at all, while if it ISN'T satisfied, then it would raise errors just like an assignment to a type (including excess property checks for literals) giving local and immediate feedback that it wasn't like the type you asked.

  19. RyanCavanaugh commented on Feb 16, 2022

    @RyanCavanaugh
    Member

    I'd like to pick up the conversation at #47920 to get a clean comment slate. There's a large write-up there.

  20. spyro2000 commented on Mar 21, 2023

    @spyro2000

    I would suggest something simple like

    Case 1 ("strict cast"):

    {prop: 123} as! MyInterface // fails on missing properties or properties not satisfying MyInterface

    Case 2 ("safe cast")

    {prop: 123} as? MyInterface // returns null if cast not successful

    Case 3 ("unsafe cast")

    {prop: 123} as MyInterface // just labels the object as the specified interface (currently the only possibility)
  21. locked as resolved and limited conversation to collaborators on Mar 21, 2023
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

    Fix AvailableA PR has been opened for this issueFixedA 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