microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.83k stars 12.46k forks source link

"satisfies" operator to ensure an expression matches some type (feedback reset) #47920

Closed RyanCavanaugh closed 2 years ago

RyanCavanaugh commented 2 years ago

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

let a = false;
upd();
if (a === true) {
//    ^^^ error, true and false have no overlap
    // ...
}
function upd() {
    if (someCondition) a = true;
}

The canonical recommendation is to type-assert the initializer:

let a = false as boolean;

but there's limited type safety here since you could accidently downcast without realizing it:

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 };
}

The safest workaround is to have a dummy function, function up<T>(arg: T): T:

let a = up<boolean>(true);

which is unfortunate due to having unnecessary runtime impact.

Instead, we would presumably write

let p = { kind: "cat", meows: true } satisfies Animal;

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:

type Keys = 'a' | 'b' | 'c' | 'd';

const p = {
    a: 0,
    b: "hello",
    x: 8 // Should error, 'x' isn't in 'Keys'
};

// Should be OK -- retain info that a is number and b is string
let a = p.a.toFixed();
let b = p.b.substr(1);
// Should error even though 'd' is in 'Keys'
let d = p.d;

There is no obvious workaround here today.

Instead, we would presumably write

const p = {
    a: 0,
    b: "hello",
    x: 8 // Should error, 'x' isn't in 'Keys'
} satisfies Partial<Record<Keys, unknown>>;
// using 'Partial' to indicate it's OK 'd' is missing

Property Name Fulfillment

Same as Property Name Constraining, except we might want to ensure that we get all of the keys:

type Keys = 'a' | 'b' | 'c' | 'd';

const p = {
    a: 0,
    b: "hello",
    c: true
    // Should error because 'd' is missing
};
// Should be OK
const t: boolean = p.c;

The closest available workaround is:

const dummy: Record<Keys, unknown> = p;

but this assignment a) has runtime impact and b) will not detect excess properties.

Instead, we would presumably write

const p = {
    a: 0,
    b: "hello",
    c: true
    // will error because 'd' is missing
} satisfies Record<Keys, unknown>;

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:

type Facts = { [key: string]: boolean };
declare function checkTruths(x: Facts): void;
declare function checkM(x: { m: boolean }): void;
const x = {
    m: true
};

// Should be OK
checkTruths(x);
// Should be OK
fn(x);
// Should fail under --noIndexSignaturePropertyAccess
console.log(x.z);
// Should be OK under --noUncheckedIndexedAccess
const m: boolean = x.m;

// Should be 'm'
type M = keyof typeof x;

// Should be able to detect a failure here
const x2 = {
    m: true,
    s: "false"
};

Another example

export type Color = { r: number, g: number, b: number };

// All of these should be Colors, but I only use some of them here.
export const Palette = {
    white: { r: 255, g: 255, b: 255},
    black: { r: 0, g: 0, d: 0}, // <- oops! 'd' in place of 'b'
    blue: { r: 0, g: 0, b: 255 },
};

Here, we would presumably write

const Palette = {
    white: { r: 255, g: 255, b: 255},
    black: { r: 0, g: 0, d: 0}, // <- error is now detected
    blue: { r: 0, g: 0, b: 255 },
} satisfies Record<string, Color>;

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:

type Movable = {
    move(distance: number): void;
};

const car = {
    start() { },
    move(d) {
        // d should be number
    },
    stop() { }
};

Here, we would presumably write

const car = {
    start() { },
    move(d) {
        // d: number
    },
    stop() { }
} satisfies Moveable;

Optional Member Conformance

We might want to initialize a value conforming to some weakly-typed interface:

type Point2d = { x: number, y: number };
// Undesirable behavior today with type annotation
const a: Partial<Point2d> = { x: 10 };
// Errors, but should be OK -- we know x is there
console.log(a.x.toFixed());
// OK, but should be an error -- we know y is missing
let p = a.y;

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:

type Point2d = { x: number, y: number };
const a: Partial<Point2d> = { x: 10 };
// Should be OK
a.x.toFixed();
// Should be OK, y is present, just not initialized
a.y = 3;

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:

//         a: implicit any
const f1 = a => { };

//                              a: string
const f2: (s: string) => void = a => { };

In all of the above scenarios, contextual typing would always be appropriate. For example, in Property Value Conformance

type Predicates = { [s: string]: (n: number) => boolean };

const p: Predicates = {
    isEven: n => n % 2 === 0,
    isOdd: n => n % 2 === 1
};

Contextually providing the n parameters a number 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:

*SATA: Same As Type Annotation - const v = e satisfies T would do the same as const v: T = e, thus no additional value is provided

Scenario T typeof e T & typeof e
Safe Upcast ❌ (undoes the upcasting) ❌ (undoes the upcasting)
Property Name Constraining ❌ (SATA)
Property Name Fulfillment ❌ (SATA)
Ensure Interface Implementation ❌ (SATA)
Optional Member Conformance ❌ (SATA) ❌ (members appear when not desired)
Optional Member Addition ❌ (SATA) ❌ (members do not appear when desired)
Contextual Typing

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:

type Point = {
    x: number,
    y: number
};
const origin = {
    x: 0,
    y: 0,
    z: 0 // OK or error?
} satisifes Point;

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:

type Point2d = { x: number, y: number };
const a = { x: 10, z: 0 } satisfies Partial<Point2d> & Record<string, unknown>;
// Arbitrary writes allowed (super bad)
a.blah = 10;

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:

type Keys = 'a' | 'b' | 'c';
// Property 'd' might be intentional excess *or* a typo of e.g. 'b'
const v = { a: 0, d: 0 } satisfies Partial<Record<Keys, number>>;

Producing typeof e then leads to another problem...

The Empty Array Problem

Under --strict (specifically strictNullChecks && noImplicitAny), empty arrays in positions where they can't be Evolving Arrays get the type never[]. This leads to some somewhat annoying behavior today:

let m = { value: [] };
// Error, can't assign 'number' to 'never'
m.value.push(3);

The satisfies operator might be thought to fix this:

type BoxOfArray<T> = { value: T[] };
let m = { value: [] } satisfies BoxOfArray<number>
// OK, right? I just said it was OK?
m.value.push(3);

However, under current semantics (including m: typeof e), this still doesn't work, because the type of the array is still never[].

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:

Does this seem right? What did I miss?

cefn commented 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 commented 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 #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 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...

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.

cefn commented 2 years ago

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.

mgmolisani commented 2 years ago

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.

cefn commented 2 years ago

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.

RyanCavanaugh commented 2 years ago

It's a good question. Reasons we didn't want to use implements:

shicks commented 2 years ago

While 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?

fabiancook commented 2 years ago

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?

cefn commented 2 years ago

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!

juanrgm commented 2 years ago

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
johncmunson commented 2 years ago

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.

image

So, ummm, what exactly is the lesson here?

joshkel commented 2 years ago

@johncmunson, I'm not sure I understand your issue.

Based on my understanding:

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.

shicks commented 2 years ago

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.

joshkel commented 2 years ago

@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").

RyanCavanaugh commented 2 years ago

@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.

johncmunson commented 2 years ago

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.

cefn commented 2 years ago

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

shicks commented 2 years ago

The satisfies operator does sustain safe upcast, if verbosely, like satisfies 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.

NikolaRHristov commented 2 years ago

Doesn't work on

export default {} satisfies T;
[ERROR] Expected ";" but found "satisfies"

in typescript@4.9.1-beta

IllusionMH commented 2 years ago

It not supposed to work on export statement, as this is expression level operator (in 4.9).

You should track #38511

shicks commented 2 years ago

Seems to work for me.

interface Foo {
    foo?: number;
}
export default {} satisfies Foo;
dan-mk commented 2 years ago
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?

cefn commented 2 years ago

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 ?

image

dan-mk commented 2 years ago

@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.

shicks commented 2 years ago

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).

MikeRippon commented 2 years ago

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
shicks commented 2 years ago

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.

bradennapier commented 1 year ago
image image

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:

image
ftonato commented 1 year ago

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?

TS Playground (URL for playing the behaviour).

mmkal commented 1 year ago

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)
josh-hemphill commented 1 year ago

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.

ftonato commented 1 year ago

@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'

TS Playground

ricklove commented 1 year ago

I think this satisfies my very old proposal too - which could be closed: https://github.com/microsoft/TypeScript/issues/19550

ftonato commented 1 year ago

@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...

josh-hemphill commented 1 year ago

@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.

somebody1234 commented 1 year ago

@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

BribeFromTheHive commented 1 year ago

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.

RyanCavanaugh commented 1 year ago

This would be a great feature to have.

Great news for you

BribeFromTheHive commented 1 year ago

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!

maksverver commented 3 months ago

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; }