Closed RyanCavanaugh closed 2 years ago
@phryneas I don't think either of the choices you describe will give you what you want. A satisfies function and indeed the satisfies operator we have been discussing has the behaviour of preserving the expression type, while validating it against the satisfies
type, so you'll end up with a Slice that only has the possible shape of { state: "foo" }
.
My understanding is based on the terse and powerful argument in https://github.com/microsoft/TypeScript/issues/47920#issuecomment-1054480996 which guides the design towards the satisfies
operator resolving to the expression type, not the asserted type.
I would suggest patterns such as those below, which pass on the broad shape of State
to the slice through conventional typing, while constraining initialState
correctly. These patterns are already available in the language, while this issue relates to satisfies
making things possible which are currently impossible via type annotations alone...
const initialState: State = { state: "foo" };
createSlice({
initialState
})
...or...
createSlice<State>({
initialState: { state: "foo" }
})
@phryneas I don't think either of the choices you describe will give you what you want. A satisfies function and indeed the satisfies operator we have been discussing has the behaviour of preserving the expression type, while validating it against the
satisfies
type, so you'll end up with a Slice that only has the possible shape of{ state: "foo" }
.My understanding is based on the terse and powerful argument in #47920 (comment) which guides the design towards the
satisfies
operator resolving to the expression type, not the asserted type.
In that case, I might actually have misunderstood the proposal. I will have to reread the whole thing I guess.
I would suggest patterns such as those below, which pass on the broad shape of
State
to the slice through conventional typing, while constraininginitialState
correctly. These patterns are already available in the language, while this issue relates tosatisfies
making things possible which are currently impossible via type annotations alone...
Unfortunately, both of those will not work
const initialState: State = { state: "foo" }; createSlice({ initialState })
This approach will in many cases ignore the type annotation State
completely because code flow analysis will tell TS that { state: "foo" }
has never been reassigned before calling createSlice
- and createSlice
will be nailed down to an argument of initialState: { state: "foo" }
, even though the user explicitly stated the type State
would be wider. It is too narrow.
It might not be the case in this specific instance, but we had enough of those cases (e.g. https://github.com/reduxjs/redux-toolkit/issues/735 ) that we had to replace all mentions of const initialState: State = { state: "foo" };
in our docs with const initialState = { state: "foo" } as State;
to prevent code flow analysis from kicking in - even if it meant losing a lot of type safety.
...or...
createSlice<State>({ initialState: { state: "foo" } })
This one will not work either, because as stated in my code examples, createSlice
has other generic arguments that have to be inferred and TypeScript cannot mix inferred generic arguments with non-inferred generic arguments. Otherwise the user would in some cases write dozens or even hundreds of lines of generics in the createSlice
call.
code flow analysis will tell TS that { state: "foo" } has never been reassigned before calling createSlice - and createSlice will be nailed down to an argument of initialState: { state: "foo" }
Wow I've used this pattern a lot and did not know this could ever happen, (I thought explicit typing prevented the declaration analysis controlling the type). Offline I'd value a look at any Typescript playground where this can be demonstrated as I'm much less confident of my Typescript experience given what you've said (Twitter @cefn DMs are open). Thanks for sharing.
we had to replace all mentions of const initialState: State = { state: "foo" }; in our docs with const initialState = { state: "foo" } as State; to prevent code flow analysis from kicking in - even if it meant losing a lot of type safety
If the satisfies
feature is added as per https://github.com/microsoft/TypeScript/issues/47920#issuecomment-1054480996 you would have satisfies State as State
which you could use in the place of as State
to combine both type-checking and control of its type nature, so you wouldn't have to lose type safety.
Guess I'm 19 hours late but wondering why there was no further discussion on the difference between a new keyword and the reuse of the implements
keyword which seems to fulfill the same mental model.
I wrote a brief Medium article concerning the satisfies
operator at https://medium.com/@cefn/typescript-satisfies-6ba52e74cb2f
I welcome feedback via cefn.com, Twitter (DMs open) or just comment on the article if it's misleading or can be improved to better capture the new possibilities.
It's a good question. Reasons we didn't want to use implements
:
implements
is already a JavaScript reserved word, so TC39 may want to use this keyword to do something in the future. You might think using a reserved word makes more sense rather than less but some committee members reasonably disagree about this, since they would have liked to e.g. use interface
declarations to do something else, which is effectively closed off as an avenue now due to TS's use of itclass A extends B implements C {
- by normal precedence rules, this would probably want to be parsed as class A extends (B implements C) {
implements
changes the typing of otherwise-implicitly-any parameter/properties in class declarationsconst e = class A implements B { } implements C
to have slightly different semantics between B
and C
is just incredibly confusingWhile I'm a little lukewarm about this feature as-is (in particular, the safe-upcast use case is much better written as an assignment to a declaration with an explicit type annotation, and I think there's a lot of value in explicit function calls that can be no-op in production but do real runtime checks in develompent), I think it leads to a very natural extension that could potentially solve #23689 as well. If you could write satisfies
as a type constraint that doesn't actually establish a lower bound (the way extends
does), then you could force much earlier/clearer detection of incorrect type usage:
type Foo<T satisfies SomePredicateThatMightOtherwiseIntroduceCyclicDependencyOn<T>> = ...;
See this more complete playground example.
Would this sort of extension be reasonable?
Am linking https://github.com/microsoft/TypeScript/issues/34319 to this issue too, it appears to have overlap, where #34319 is focusing in just on function implementation.
e.g.:
interface CoolFunction {
(first: string, second: number): number;
(first: number, second: string): string;
property?: boolean;
}
// args will be of type [string, number] | [number, string]
function coolFunction(...args) implements CoolFunction {
if (typeof args[0] === "string") {
// We're now reduced our type of args to [string, number]
return args[1];
} else {
// args can only be [number, string]
return args[1];
}
}
As mentioned by in https://github.com/microsoft/TypeScript/issues/47920#issuecomment-1049032918 this could be used for a function declaration
// args will be of type [string, number] | [number, string]
function coolFunction(...args) {
if (typeof args[0] === "string") {
// We're now reduced our type of args to [string, number]
return args[1];
} else {
// args can only be [number, string]
return args[1];
}
} satisfies CoolFunction
Maybe satisfies
could be excluded for functions (& classes) in favour of implements
, instead of mixing the two.
The more recent comment about not using implements makes sense, so maybe it is an argument to close https://github.com/microsoft/TypeScript/issues/34319 ?
Or maybe they are just completely different use cases and both should exist?
the safe-upcast use case is much better written as an assignment to a declaration with an explicit type annotation
Unfortunately as per https://tsplay.dev/w1pxGW a cast and an assignment are not (yet) equivalent. Although I would like them to be!
Even aside from these issues in code path analysis, over several years of Typescript code review I have found in practice a lot of inline upcasts which should have been safe upcasts, probably using the approach you suggest, or explicit generics should have been used rather than forcing the type of some value to influence the generic.
Unfortunately the force-generic trick is adopted in so much API design and documentation, and the assignment to a variable or passing through a noop is judged an unnecessary runtime overhead, so people just don't do it. Especially where the original assignment isn't broadened (because it needs to retain its type information), it is hard to convince people (and linters) that there should be a second assignment to an unused shadow variable which is there purely for type-checking. They just don't think like a compiler. They go ahead and type as T
saying they know it's the right shape.
So it's easier for me to convince people never to write as T
but always check it first inline with satisfies T as T
and unlike assignment I can tell them it has no runtime cost. This will mean the only as T
still lurking in the codebase are the weird ones that deserve attention and commenting because Typescript can make any assessment, and I might even convince people these should be guard functions!
Why not Exact type?
type Exact<T> = T type Props = { color: string } ({ color: "red", margin: 1 }) as Props; // no error ({ color: "red", margin: 1 }) as Exact<Props>; // margin error function b(fn: () => Exact<Props>) {} b(() => ({ color: "red", margin: 1 })) // margin error
type Props = { color: string }
({ color: "red", margin: 1 }) as Props; // no error
({ color: "red", margin: 1 }) satisfies Props; // margin error 👍
function b<T satisfies Props>(fn: () => T) {} // 😢
b(() => ({ color: "red", margin: 1 })) // margin error
Looks like you may have a problem with your very first example.
This is what we started with...
type Animal = { kind: "cat", meows: true } | { kind: "dog", barks: true };
let p = { kind: "cat" } as Animal; // Missing meows!
upd();
if (p.kind === "dog") {
} else {
p.meows; // Reported 'true', actually 'undefined'
}
function upd() {
if (Math.random() > 0.5) p = { kind: "dog", barks: true };
}
Then, it was suggested...
// Instead, we would presumably write
let p = { kind: "cat", meows: true } satisfies Animal;
Okay, cool, let's make that update...
type Animal = { kind: "cat", meows: true } | { kind: "dog", barks: true };
let p = { kind: "cat", meows: true } satisfies Animal;
upd();
if (p.kind === "dog") {
} else {
p.meows;
}
function upd() {
if (Math.random() > 0.5) p = { kind: "dog", barks: true };
}
And here's where we end up at... compiler errors.
So, ummm, what exactly is the lesson here?
@johncmunson, I'm not sure I understand your issue.
Based on my understanding:
as Animal
means "Trust me, this is of type Animal
, regardless of what it looks like."satisfies Animal
means "This type doesn't change - it's whatever it looks like it is - however, if it's not also a valid Animal
, then complain."So
let p = { kind: "cat", meows: true } satisfies Animal;
means "p
is as an object literal of { kind: "cat", meows: true }
that, when initialized, is also a valid Animal
." So TypeScript is correct in telling you that p.kind === 'dog'
can never be true and upd
's assignment isn't allowed.
I think the issue @johncmunson is concerned about is orthogonal - it's the fact that type narrowing doesn't correctly track side effects. So if you have a narrowed type but a side-effectful function changes the narrowing, the calling scope's flow-sensitive narrowing is unaware of it. This is an issue independent of whether you're using satisfies
to improve diagnostics.
@shicks But, similar to what you said, that's orthogonal to using satisfies
: The p.kind === 'dog'
check also throws an error if I never touch satisfies
and instead use let p: Animal = { kind: "cat", meows: true };
. This is discussed further in #9998 (TypeScript's use of "optimistic control flow analysis").
@johncmunson this was described as the "safe upcast" scenario and ruled out as a problem that could be solved while still addressing other more intractable scenarios.
Discussion [...] Given the value of the other scenarios, I think safe upcast needs to be discarded.
Ah, very very helpful, thank you for the clarification guys. This comment helps me understand the raison d'etre for satisfies
, and I also see why the safe upcast scenario was discarded.. b/c of pre-existing difficulties with control flow analysis and tracking side effects.
My confusion stemmed from the fact that someone linked me this page saying this is how the new satisfies
operator works, but I did not realize that this thread started out as a sort-of-RFC and therefore the examples given were not set in stone.
The satisfies
operator does sustain safe upcast, if verbosely, like satisfies T as T
See this comment from Ryan https://github.com/microsoft/TypeScript/issues/47920#issuecomment-1054480996
Also I attempted to document the settled behaviour and raisin d'etre for the final feature, including safe upcast as per https://github.com/microsoft/TypeScript/issues/47920#issuecomment-1230266968
The
satisfies
operator does sustain safe upcast, if verbosely, likesatisfies T as T
Or, if you value DRY at the expense of runtime pollution, function safeUpcast<T>(arg: T): T { return arg; }
. There's trade-offs either way.
Doesn't work on
export default {} satisfies T;
[ERROR] Expected ";" but found "satisfies"
in typescript@4.9.1-beta
It not supposed to work on export
statement, as this is expression level operator (in 4.9).
You should track #38511
interface Foo {
foo?: number;
}
export default {} satisfies Foo;
export type Client = {
name: string;
age: number;
height: number;
}
function test<
X extends [A, B],
A satisfies { [a: string]: number | string | boolean | null },
B extends (keyof A)[],
>(x: X) {
return x;
}
test([
<Client> { name: 'John', age: 20, height: 180 },
['name', 'this is not a valid key'],
]);
Is there any plan to make it work with generics so that the example above will tell me that 'this is not a valid key'
is not a valid key of Client?
Is there any plan to make it work with generics so that the example above will tell me that 'this is not a valid key' is not a valid key of Client?
@dan-mk what's missing from the current capability as demonstrated in https://tsplay.dev/wgQglm ?
@cefn you're right, it's already possible. I still don't fully get the way type inference works in that scenario, but this particular example helped me to understand it better, thanks. However, I still think satisfies could help with generics too, at least making the sintax simpler.
The one place extends
doesn't work is when you need a more complex type predicate, rather than a simple subtype constraint. For instance, I need a function that only accepts enum containers, but not arbitrary Record<string, number|string>
. This isn't strictly possible (see #30611), but you can get a decent approximation with
type ValidEnumType<T, E = T[keyof T]> =
[E] extends [string] ? (string extends E ? never : T) :
[E] extends [number] ? (true extends({[key: number]: true}&{[P in E]: false})[number] ? T : never) :
never;
declare function useEnum<T>(arg: T extends ValidEnumType<T> ? T : never): void;
Note the weird usage of the conditional type in the parameter. I would prefer to be able to write
declare function useEnum<T extends ValidEnumType<T>>(arg: T): void;
but this is not allowed because it introduces a circular dependency. In this case we can get away with it because we can force a required parameter to be never
when the predicate fails, but imagine a case where the type is explicitly specified and there are no parameters:
type IsEvenLength<T extends string> =
T extends '' ?
T : T extends `${infer _A}${infer _B}${infer U}` ?
U extends IsEvenLength<U> ? T : never : never;
function listOfEvenLengthStrings<T extends string>(): IsEvenLength<T>[] {
return [];
}
In this case, the best you can do is try to mangle the return type. Above, I've just filtered out the bad inputs; you could also do something like returning T extends IsEvenLength<T> ? T[] : never
, but even still, you cannot force an error at the call site.
Both of the above examples could be improved by simply allowing <T satisfies ...>
in the generic declaration as a way to add a requirement without establishing a lower bound (and thus a circular reference).
Not a guru, so I'm assuming this might well have some holes or drawbacks that I'm not aware of, but thought I'd post it here in case anybody else wants a workaround while waiting for 4.9 to land. It seems to work for the scenarios I care about (property name conformance & fullfilment without losing inferred type).
It leans on the fact that a generic function parameter can be type checked using extends
without erasing any information about the original type.
const satisfies = <SuperType>() => <Actual extends SuperType>(value: Actual): Actual => value
Example usage based on OP:
export type Color = { r: number, g: number, b: number };
export type Palette = Record<string, Color>
// Option 1: Inline usage (greater runtime overhead)
export const palette1 = satisfies<Palette>()({
white: { r: 255, g: 255, b: 255 },
black: { r: 0, g: 0, b: 0 },
blue: { r: 0, g: 0, b: 255 },
});
palette1.blue // Ok
// Option 2: Helper method for usage in multiple places
export const validPalette = satisfies<Palette>()
export const palette2 = validPalette({
white: { r: 255, g: 255, b: 255 },
black: { r: 0, g: 0, b: 0 },
blue: { r: 0, g: 0, b: 255 },
})
palette2.blue // Ok
That's a creative workaround.
FWIW, Option 1 would be a bit cleaner if we had #26242 (and you could use a different function for Option 2 in that case). Just wanted to mention this as another use case and get the issues linked.
It is not the prettiest way to define a function but it does the job and the result is awesome - retains the actual types and therefore infers the generic of SpotServicesController
whereas if i just did const binance: SpotServicesController
i would need to do const binance: SpotServicesController<BinanceTickerResult>
Not to mention the IDE tips become much less useless without jumping around to diff files to track down what the type is:
I found the concept of the operator very interesting, and given the following example, I only have one question for which I haven't found an answer:
interface User {
name: string,
age: number | null
}
const people: User[] = [
{
name: 'John Doe',
age: 15,
},
{
name: 'Foo',
age: null,
}
]
const peopleWithTypeAssertion = [
{
name: 'John Doe',
age: 15,
},
{
name: 'Foo',
age: null,
}
] as User[]
console.log(people[0].age + 15) // Object is possibly 'null'
console.log(peopleWithTypeAssertion[0].age + 15) // Object is possibly 'null'
// ----
const peopleWithSatisfies: User[] = [
{
name: 'John Doe',
age: 15,
},
{
name: 'Foo',
age: null,
}
] satisfies User[];
console.log(peopleWithSatisfies[0].age + 15) // Object is possibly 'null'
The last line is currently showing Object is possibly 'null'
and I was hoping not to face this error with the satisfies
operator, what am I missing here?
This seems to work (as const satisfies readonly User[]
), but I agree it's a shame to have to use so many keywords. It's hard to imagine beginners guessing this and it's probably the desired behavior in most cases:
const people = [
{
name: 'John Doe',
age: 15,
},
{
name: 'Foo',
age: null,
}
] as const satisfies readonly User[]
console.log(people[0].age + 1)
The issue is the array index. If you do this it works:
const peopleWithSatisfies = [
{
name: 'John Doe',
age: 15,
},
{
name: 'Foo',
age: null,
}
] satisfies [User,User];
Because User[]
does not specify what is at what index, peopleWithSatisfies[0]
could be either.
You need static indecies.
@josh-hemphill but if the array index is the problem, shouldn't that work?
interface User {
name: string,
age: number | null
}
const peopleWithSatisfies: User[] = [
{
name: 'John Doe',
age: 15,
},
{
name: 'Foo',
age: null,
}
];
const user = peopleWithSatisfies[0] satisfies User
console.log(user.age + 15) // Object is possibly 'null'
I think this satisfies
my very old proposal too - which could be closed: https://github.com/microsoft/TypeScript/issues/19550
@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 be readonly
, as in my case...
@ftonato If you look at the inferred type by hovering peopleWithSatisfies
you can see
const peopleWithSatisfies: ({
name: string;
age: number;
} | {
name: string;
age: null;
})[]
You need to use as const
or some other way of setting explicit indecies like replacing User[]
with [User,User]
.
You're still specifying a type of User[]
on the variable declaration.
@ftonato (cc @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 assignable
This 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.
This would be a great feature to have.
Funny 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!
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 satisfies
constraint 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; }
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
n
parameters anumber
type 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 T
expression:typeof e
T
T & typeof e
*SATA: Same As Type Annotation -
const v = e satisfies T
would do the same asconst v: T = e
, thus no additional value is providedT
typeof e
T & typeof e
Discussion
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
z
an excess property?One argument says yes, because in other positions where that object literal was used where a
Point
was 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
satisfies
operator 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 e
becomes 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 e
then 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
satisfies
operator 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 e
as the expression type instead ofT
orT & typeof e
Does this seem right? What did I miss?