Repository navigation
"satisfies" operator to ensure an expression matches some type (feedback reset) #47920
Description
Activity
- addedSuggestionAn idea for TypeScriptAn idea for TypeScriptIn DiscussionNot yet reached consensusNot yet reached consensus
on Feb 16, 2022 If you discard safe upcast you shouldn't probably close #7481 with the current issue as a solution as it was only about safe upcast which somehow morphed into discussion about something else. I totally see the value in the other scenarios, but it seems to be a different feature.
EDIT: now after some thinking I think you are right there are not many scenarios left where safe upcast is needed if
satisfiesexisted.Agree it should be
typeof efor my expectations of the operator.Can't we expect people to use
as T[]to explicitly type empty arrays if they expect the compiler to help them. Usingasin this case can have no runtime consequences at the point of assignment (not having any members which could incorrectly be inferred as type T). In your example it would belet m = { value: [] as number[] }. I find I have to do this once in a while.I don't think the approach to
satisfiesis responsible for a situation which already allows drifts in array nature arising from push, not captured by the compiler, like this example even for non-empty arrays...type Tuple = [number, number]; function tackOneOn(tuple: Tuple){ tuple.push(4) return tuple; } const mutated: Tuple = [3,4] const returned = tackOneOn(mutated); // also Tuple, refers to mutated, but is 3 long const howMany = returned.length // type 2
Reacted by Qwerty (Vítězslav Ackermann Ferko), Joe Percy and Alexander Drozdovethanresnick commented
on Feb 16, 2022 ContributorMore actionsI totally agree with discarding the safe upcast case in favor of the other scenarios.
As far as using
typeof evstypeof e & Tas the type fore satisfies T... I think we definitely want to incorporate some information fromTinto the final type. Your example with the empty arrays shows this, but I think it also applies with the Ensure Interface Implementation example. I.e., in that example, we want the argument tomoveto be typed as anumber, notany, so we can't 100% throw away the type information inMovable. I think that's what people were trying to accomplish by usingtypeof e & T.What I don't really understand well enough is how exactly contextual typing comes into play here. Like, I think the contextual typing process could allow us to incorporate some information from the type
Twithout literally creating an intersection type? If so, that seems like the way to go. But then couldn't that also handle the empty array case without any special treatment?RyanCavanaugh commented
on Feb 16, 2022 MemberAuthorMore actionsContextual typing will set
move's parameter tonumber; no additional mechanics are needed there.The fact that parameters get their types from contextual typing but arrays don't is a sort of barely-observable difference (today) that this operator would make very obvious, hence the PR to change empty arrays.
ethanresnick commented
on Feb 16, 2022 ContributorMore actionsRyan Cavanaugh (@RyanCavanaugh) Thanks. Gotcha. That seems like a good change then, almost independent of what happens with
satisfies.ethanresnick commented
on Feb 16, 2022 ContributorMore actionsFor excess property checking... I honestly have no idea. But I'm a bit confused about how
zcould be an excess property in:const origin = { x: 0, y: 0, z: 0 // OK or error? } satisifes Point;
while
startandstopwould not be excess properties in:const car = { start() { }, move(d) { // d: number }, stop() { } } satisfies Moveable;
I wasn't expecting any Contextual Typing at all arising from
satisfiesand this was a surprise.This would clutter my comprehension of the likely consequences of the operator. I was expecting
satisfiesto be an uncomplicated request for validation support (e.g. please check but don't type). Adding in expectations of Contextual Typing places it into a different category so I would have to do more work to reason about it.Before reading this aspect of the proposal I expected to fulfil all the requirements of the type system when declaring or assigning (using the normal tools), but
satisfieswould let me ask the compiler 'did I get it right' for some well-defined validation.Reacted by Joshua Chen, Jeff, independer-testteam and Alex BreenReacted by Mariusz PawelskiRyanCavanaugh commented
on Feb 17, 2022 MemberAuthorMore actionsThis would clutter my comprehension of the likely consequences of the operator.
Consider this example:
const car = { start() { }, move(d) { // d: number }, stop() { } } satisfies Moveable;
Without contextual typing,
dwould be an implicitanyerror, even though we know from the fact that you wrotesatisfies Moveablethat you want it to benumber. Can you talk more about why that'd be desirable?ethanresnick commented
on Feb 17, 2022 ContributorMore actionsThis may come across as a bit of a non-sequitur, but, if we're thinking about excess property checking rules, it also seems like we should make sure that the ultimate design for
satisfiescan play well with IDE autocomplete and the "Rename Symbol" refactoring.With
satisfies, I think excess property checking's primary purpose would be to catch two kinds of potential mistakes: typos, and properties that accidentally get left with their old names after a refactor. However, autocomplete and "Rename Symbol" in the IDE can prevent the same mistakes — typos and legacy property names — that excess property checking is trying to catch!Of course, editor assistance features can't fully replace excess property checks; each has clear strengths and limits. But I do think the same concerns that motivate checking for excess properties with
satisfies(which isn't strictly necessary) would suggest making sure there can be good TS language server support too.I'd hate to land on a design that somehow makes an automatic refactor like this hard to implement:
export type Color = { r: number, g: number, b: number }; export const Palette = { white: { r: 255, g: 255, b: 255}, black: { r: 0, g: 0, b: 0}, blue: { r: 0, g: 0, b: 255 }, } satisfies Record<string, Color>;
Here, I think I should be able to rename
r->red,g->greenandb->bluein theColortype, using "Rename Symbol" in my IDE, and have that rename all the properties in the color objects inPalette. Also, if I start adding a newgreenkey toPallete, I think I should get autocomplete when writing the keys in the new object that I'm using as the value.Perhaps it's roughly-equally easy to support that kind of IDE behavior for any of the designs we're considering here, in which case this really is orthogonal/can be ignored in this thread. But I'm just raising it because I have no idea how that language server stuff works.
Reacted by Brian Bugh and Balázs ÉdesUp until recently I was in the "
satisfiesas safe upcast" camp, but I was just presented with an argument against it. Someone asked why this code didn't typecheck:interface ApiResponseItem { } interface ApiResponse { [ index: string ]: string | number | boolean | ApiResponseItem | ApiResponseItem[] } let x : ApiResponse = { items: [ { id: 123 } ], bob: true, sam: 1, sally: 'omg', } console.log(x); class Test { private result : boolean | undefined; doIt () { let person : ApiResponse = { bob: true }; this.result = person.bob; // <-- error here } }
Presumably the person who wrote this code wanted to ensure upfront that the object literal was a valid
ApiResponse, but still expected TS to treatperson.bobasboolean, as if there was no type annotation (they had worked around it with a cast toas boolean, which is very unsafe!). I had to explain that by including the type annotation, they were asking the compiler to forget about the actual contents of the object literal. If{ ... } satisfies ApiResponseonly affected contextual typing without acting as an upcast, it would be an elegant solution to this problem.Reacted by Charlie Harding and dan-mkRyan Cavanaugh (@RyanCavanaugh) I like the idea, but have you considered
implementsas the operator? (Though at the same time, it'd be worth verifying with TC39 whether that would run TS into trouble later.)Hey, thanks Ryan Cavanaugh (@RyanCavanaugh) for considering my feedback! Here’s some thoughts why Contextual Typing might not be desirable in my view.
DEFEATS PURPOSE
Assuming the intent that
satisfieswould validate typing, adding Contextual Typing might even somewhat defeat its purpose. Given the example you shared, I was expectingnoImplicitAnyto force me to be explicit about typing, withsatisfieschecking I got it right. I’d expect to be able to copy-paste theconst cardeclaration somewhere else, knowing that I had satisfiedMoveable.const car = { start() { }, move(d) {}, stop() { } } satisfies Moveable;
EXPLANATORY SIMPLICITY, LEAST SURPRISE
I want to guide colleague adopters of Typescript with simple statements about the behaviour of the language.
As per other comments in this thread, adopters often seem to think that ‘as X’ is a suitable workaround to ensure typing when it is normally exactly the opposite. It would be easy to guide them towards typing the structure with all the pre-existing tools then use
satisfiesif they want the compiler to help them with localised warnings. Ifsatisfiesdoesn’t pass without errors, they’ve missed something and should fix it.Explaining to them that
satisfiesdoes type-checking with no effect on types is helpfully simple and provides an answer to what they should do instead ofas X. It means the author is still responsible to locally fulfil the type of everything in the normal way, and thatsatisfiesdoes compile-time validation a bit likeexpectdoes build time assertion.even though we know from the fact that you wrote satisfies Moveable that you want it to be number
To give an idea of how the Contextual Typing augmentation tripped me up, this was as if I told you that `expect(value).toBe(true) would actually set the value in some circumstances (because you said you expected that) !
Having this dual role makes it a different category of operator to my mind. Following the
expectanalogy it wouldn’t be easy to separate the ‘setup’ part from the ‘assert’ part of a test - they would be blended. You would have to step up your game to deal with its special cases and in my view, those socialising the language would find it harder to share.OCKAM’S RASOR
When I have hit the ‘dead ends’ which required this operator, the feature which wasn’t available in any other way was a form of type-checking without typing for which no mechanism existed at all.
By contrast, blending in a Contextual Typing feature here isn’t driven by necessity, as all the typing you describe can be easily achieved in other ways (including existing Contextual Typing mechanisms). In the case you shared we would simply have to type
dthrough any of the normal mechanisms.const car = { start() { }, move(d: number) {}, stop() { } } satisfies Moveable;
I speculate if this is more likely in any real case anyway and would explicitly benefit from the existing Contextual Typing feature
const car: Moveable & Service = { start() { }, move(d) {}, stop() { } };
TYPE LOCALISATION PRACTICE
Maybe worth noting some community practice even tries to force types to be explicit when they can be inferred. So I believe some would see concrete benefit from seeing
move(d: number)in codebases. Personally I don’t think explicit-function-return-type should be a default typescript eslint rule at all, but the community disagrees with me. It makes me less worried about the language requiringdto be typed even in the presence ofsatisfies.SUMMARY
Based on a minimal model of what satisfies could do (it checks if something is satisfied) having it actually add types to
dviolates least surprise for me personally.I accept that others may have a more nuanced model and won’t need the operator to be cleanly validating with no 'side effects'. Looking forward to seeing how the feature develops!
Reacted by Stephen Wade, Jeff, Eric R, Joe Percy, Mark Penner, dan-mk, Jonathan Ziller and Matthew DeanReacted by Igor Oleinikov, Mariusz Pawelski and Charlie HardingRyan Cavanaugh (@RyanCavanaugh) I'm not sure how you picture the following examples when you go with the contender
typeof e:const a = [1, 2] satisfies [number, number]; const b = [1, 2] satisfies [unknown, unknown]; const c = [1, 2] satisfies [unknown, 2]; const d = {a: 1, b: 2} satisfies {a: unknown, b: 2};
If you assign
const e = [1,2], thentypeof ewould becomenumber[], but I would hope that:typeof ais now safely "downcasted" to[number, number]typeof bis also[number, number].typeof cis[number, 2].typeof dis{a: number, b: 2}.
Reacted by xlboy, Charlie Harding and Sebastian Fredriksson Bernholtz75 remaining items
Misha Kaletsky (@mmkal) that's a great point, "how easy is it for beginners to use such a structure?"
Another thing is that you defined
readonly, which means that it will not change, but removing that keyword does not work either. We don't always want to force a data structure to bereadonly, as in my case...Ademílson Tonato (@ftonato) If you look at the inferred type by hovering
peopleWithSatisfiesyou can seeconst peopleWithSatisfies: ({ name: string; age: number; } | { name: string; age: null; })[]
You need to use
as constor some other way of setting explicit indecies like replacingUser[]with[User,User].
You're still specifying a type ofUser[]on the variable declaration.Reacted by Charlie Harding and Ademílson Tonato- added 4 commits that reference this issue
on Nov 24, 2022 Ademílson Tonato (@ftonato) (cc Jo (@josh-hemphill)) note that you can forcibly narrow to a tuple by doing
satisfies [] | User[]- the[]there forces typescript to narrow the type to a tuple, just in case it does turn out to be assignableThis would be a great feature to have.
Mechanically-speaking, it should enforce the same behavior as the below:
const something: SomeType = {...} //this object must conform to SomeType function foo(param: SomeType) {} foo({...}) //the passed object must conform to SomeType function bar(): SomeType { return {...} //the returned object must conform to SomeType }
Typescript is already quite powerful with type-casting, but aside from the above options, has a hard time with type enforcing.
const something = {...} as SomeType //often fails due to typecasting const somethingElse = <SomeType>{...} //same problem as with the above
In many cases, these approaches require additional boilerplate code, making them cumbersome and less ergonomic for single-use or inline situations.
Therefore, as this request already proposes, having a loose, "on the fly" type assertion is the way to go:
export default {...} satisfies SomeType //or export default {...} is SomeType //retains the same keyword already enforced within typeguard function return value enforcement.
Introducing a more concise way to enforce types would make TypeScript more developer-friendly and allow for more ergonomic solutions in cases where existing methods are too verbose.
Reacted by Qwerty (Vítězslav Ackermann Ferko)RyanCavanaugh commented
on Apr 18, 2023 MemberAuthorMore actionsThis would be a great feature to have.
Reacted by Misha Kaletsky, Robbie Campbell and Felix HungenbergReacted by Bribe, Qwerty (Vítězslav Ackermann Ferko) and IslamFunny that it does exist, when I couldn't find the root of it based on this issue, couldn't find any search engine results, and neither ChatGPT nor Bing had any info on it (obviously it's quite a new fix). So thank you for sharing!
Reacted by Misha Kaletsky and Qwerty (Vítězslav Ackermann Ferko)I found this thread looking for a way to make a function definition conform to a function type. Unless I missed something, it seems like that
satisfiesconstraint currently works for function expressions but not for function declarations.It's easiest to explain with a small example:
type FuncType = (this: {x: number}, y: number) => number; // OK! const foo1: FuncType = function foo(y) { return this.x + y; }; // OK! const foo2 = function foo(y) { return this.x + y } satisfies FuncType; // Impossible to type? //function foo(y) { return this.x + y; }
I'm currently using the first form (because the second one seems strictly less clear) but I think it would be nice if it were possible to write the third version like this:
function foo(y) satisfies FuncType { return this.x + y; }
Reacted by btoo, Charlie Harding (old account) and BalaM314For an object literal (part of a nested object) that shall have a limited set of value types (
string | undefined) and that I want to allow to be later extended{ value1: "string", value2: undefined }
I am currently using
{ value1: "string", value2: undefined } satisfies Record<string, string | undefined> as Record<string, string | undefined>
because
satisfiesdoes not change the type of the literal (as justified above) which forbids adding new keys. For such cases, I think having the combined effect as a new keywordsatisfiesasorsafeasavailable would be handy. Or is there another way (without duplicating the type, possibly introducing a wrong type assertion) how I could achieve this? Could it be done with a generic function?Reacted by Misha Kaletsky, Torleif Berger and BribeFor an object literal (part of a nested object) that shall have a limited set of value types (
string | undefined) and that I want to allow to be later extended{ value1: "string", value2: undefined }
I am currently using
{ value1: "string", value2: undefined } satisfies Record<string, string | undefined> as Record<string, string | undefined>
because
satisfiesdoes not change the type of the literal (as justified above) which forbids adding new keys. For such cases, I think having the combined effect as a new keywordsatisfiesasorsafeasavailable would be handy. Or is there another way (without duplicating the type, possibly introducing a wrong type assertion) how I could achieve this? Could it be done with a generic function ?Just do
const values: Record<string, string | undefined> = { ... }then? No need forsatisfiesorasat all.Reacted by Bribeobject literal (part of a nested object)
Torleif Berger (@Svish) that doesn’t work for part of a nested object, you’d need to define the whole object like that.
object literal (part of a nested object)
@Svish that doesn’t work for part of a nested object, you’d need to define the whole object like that.
Right, of course, you could first construct parts of the object in separately typed consts and then mount them into the target object but I see the
satisfiesoperator as acting on expressions and as such, having a "check-and-bless" operator for expressions would make a lot of sense to me (regardless of three down-votes until now).
Feature Update - February 2022
This is a feedback reset for #7481 to get a fresh start and clarify where we are with this feature. I really thought this was going to be simpler, but it's turned out to be a bit of a rat's nest!
Let's start with what kind of scenarios we think need to be addressed.
Scenario Candidates
First, here's a review of scenarios I've collected from reading the linked issue and its many duplicates. Please post if you have other scenarios that seem relevant. I'll go into it later, but not all these scenarios can be satisfied at once for reasons that will hopefully become obvious.
Safe Upcast
Frequently in places where control flow analysis hits some limitation (hi #9998), it's desirable to "undo" the specificity of an initializer. A good example would be
The canonical recommendation is to type-assert the initializer:
but there's limited type safety here since you could accidently downcast without realizing it:
The safest workaround is to have a dummy function,
function up<T>(arg: T): T:which is unfortunate due to having unnecessary runtime impact.
Instead, we would presumably write
Property Name Constraining
We might want to make a lookup table where the property keys must come from some predefined subset, but not lose type information about what each property's value was:
There is no obvious workaround here today.
Instead, we would presumably write
Property Name Fulfillment
Same as Property Name Constraining, except we might want to ensure that we get all of the keys:
The closest available workaround is:
but this assignment a) has runtime impact and b) will not detect excess properties.
Instead, we would presumably write
Property Value Conformance
This is the flipside of Property Name Constraining - we might want to make sure that all property values in an object conform to some type, but still keep record of which keys are present:
Another example
Here, we would presumably write
Ensure Interface Implementation
We might want to leverage type inference, but still check that something conforms to an interface and use that interface to provide contextual typing:
Here, we would presumably write
Optional Member Conformance
We might want to initialize a value conforming to some weakly-typed interface:
Optional Member Addition
Conversely, we might want to safely initialize a variable according to some type but retain information about the members which aren't present:
Contextual Typing
TypeScript has a process called contextual typing in which expressions which would otherwise not have an inferrable type can get an inferred type from context:
In all of the above scenarios, contextual typing would always be appropriate. For example, in Property Value Conformance
Contextually providing the
nparameters anumbertype is clearly desirable. In most other places than parameters, the contextual typing of an expression is not directly observable except insofar as normally-disallowed assignments become allowable.Desired Behavior Rundown
There are three plausible contenders for what to infer for the type of an
e satisfies Texpression:typeof eTT & typeof e*SATA: Same As Type Annotation -
const v = e satisfies Twould do the same asconst v: T = e, thus no additional value is providedTtypeof eT & typeof eDiscussion
Given the value of the other scenarios, I think safe upcast needs to be discarded. One could imagine other solutions to this problem, e.g. marking a particular variable as "volatile" such that narrowings no longer apply to it, or simply by having better side-effect tracking.
Excess Properties
A sidenote here on excess properties. Consider this case:
Is
zan excess property?One argument says yes, because in other positions where that object literal was used where a
Pointwas expected, it would be. Additionally, if we want to detect typos (as in the property name constraining scenario), then detecting excess properties is mandatory.The other argument says no, because the point of excess property checks is to detect properties which are "lost" due to not having their presence captured by the type system, and the design of the
satisfiesoperator is specifically for scenarios where the ultimate type of all properties is captured somewhere.I think on balance, the "yes" argument is stronger. If we don't flag excess properties, then the property name constraining scenario can't be made to work at all. In places where excess properties are expected,
e satisfies (T & Record<string, unknown>)can be written instead.However, under this solution, producing the expression type
T & typeof ebecomes very undesirable:Side note: It's tempting to say that properties aren't excess if all of the satisfied type's properties are matched. I don't think this is satisfactory because it doesn't really clearly define what would happen with the asserted-to type is
Partial, which is likely common:Producing
typeof ethen leads to another problem...The Empty Array Problem
Under
--strict(specificallystrictNullChecks && noImplicitAny), empty arrays in positions where they can't be Evolving Arrays get the typenever[]. This leads to some somewhat annoying behavior today:The
satisfiesoperator might be thought to fix this:However, under current semantics (including
m: typeof e), this still doesn't work, because the type of the array is stillnever[].It seems like this can be fixed with a targeted change to empty arrays, which I've prototyped at #47898. It's possible there are unintended downstream consequences of this (changes like this can often foul up generic inference in ways that aren't obvious), but it seems to be OK for now.
TL;DR
It seems like the best place we could land is:
T & Record<string, unknown>)typeof eas the expression type instead ofTorT & typeof eDoes this seem right? What did I miss?