Repository navigation
Operator to ensure an expression is contextually typed by, and satisfies, some type #7481
Description
Activity
RyanCavanaugh commented
on Mar 11, 2016 MemberMore actionsCan you post a few examples of how you'd like this to work so we can understand the use cases?
magnushiie commented
on Mar 12, 2016 ContributorAuthorMore actionsOne 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
isoperator 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);
Reacted by Qwerty (Vítězslav Ackermann Ferko), Marc Bornträger, Aaron Sherwood and Michal Minich- addedSuggestionAn idea for TypeScriptAn idea for TypeScriptNeeds ProposalThis issue needs a plan that clarifies the finer details of how it could be implemented.This issue needs a plan that clarifies the finer details of how it could be implemented.
on Mar 12, 2016 DanielRosenwasser commented
on Mar 12, 2016 MemberMore actionsI 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".
Reacted by Rui Pimentel, Piotr Monwid-Olechnowicz, Qwerty (Vítězslav Ackermann Ferko), Felipe, clement-estone, Tommy Troy Lin, Joe Alden, Marko Kaznovac, Brian Frichette, Aaron Sherwood and 2 moreRyanCavanaugh commented
on Mar 14, 2016 MemberMore actionsSounds a lot like #2876?
magnushiie commented
on Mar 14, 2016 ContributorAuthorMore actionsIf 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, theisoperator 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 : natis 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.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
thenisPromiseLike<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.
magnushiie commented
on Sep 14, 2016 ContributorAuthorMore actionsChris 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.
Odd, I thought I had a case where an assignment such as
let x: Foo = {...};would show a compile error while a cast such aslet 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; });
Could we just use
isthe same way asas?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
Reacted by Marin Marinov, Blake Embrey, Aluan Haddad, jwbay, Stanislav Panferov, David Rodrigue, Ian MacLeod, Retsam, Qwerty (Vítězslav Ackermann Ferko), fqborges and 46 moreReacted by Qwerty (Vítězslav Ackermann Ferko) and leppaottaluanhaddad commented
on Dec 29, 2016 ContributorMore actions@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.
Reacted by normalser, Alexander Mills and Shannon MatthewsI like this idea of effectively a static type assertion.
Reacted by Aaron Sherwood and spyro2000magnushiie commented
on Mar 29, 2017 ContributorAuthorMore actions- addedIn DiscussionNot yet reached consensusNot yet reached consensus
on Mar 29, 2017 80 remaining items
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.
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 theT 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 forfunction isEnum<T satisfies Enum<T>>orfunction 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_assertin 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
isEnumfunction typechecks no matter what, it just returns an unusable value. This also relies on there being usable and unusable values: if the function returnedvoid, for instance, this wouldn't work so well.EDIT: added syntax highlighting
One other reason in favour of allowing
satisfiesto "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
calculateto be areadonly [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 useas [number, number]. This seems like a solid solution, but it is actually quite dangerous, because what if now a refactor made getTuple to returndeclare 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); }
Reacted by Sander Mol, Ryan Cavanaugh, Ethan Resnick, Niels Simonides, Johannes Pilkahn, Charlie Harding and Gabriele TomberliReacted by Toni Villena-
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) { ... } }
satisfieswould 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.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
likeoperator - 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
likethe type you asked.RyanCavanaugh commented
on Feb 16, 2022 MemberMore actionsI'd like to pick up the conversation at #47920 to get a clean comment slate. There's a large write-up there.
Reacted by Qwerty (Vítězslav Ackermann Ferko)- addedFixedA PR has been merged for this issueA PR has been merged for this issueand removedIn DiscussionNot yet reached consensusNot yet reached consensus
on Aug 26, 2022 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)
Reacted by noReacted by Andrii Dieiev- locked as resolved and limited conversation to collaborators
on Mar 21, 2023
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 ofas.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.
EDIT: Due to parameter bivariance, this function is not equivalent to the proposed operator, because asType allows downcasts too.