microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.41k stars 12.56k 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?

magnushiie commented 2 years ago

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 satisfies existed.

cefn commented 2 years ago

Agree it should be typeof e for 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. Using as in 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 be let m = { value: [] as number[] }. I find I have to do this once in a while.

I don't think the approach to satisfies is 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
ethanresnick commented 2 years ago

I totally agree with discarding the safe upcast case in favor of the other scenarios.

As far as using typeof e vs typeof e & T as the type for e satisfies T... I think we definitely want to incorporate some information from T into 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 to move to be typed as a number, not any, so we can't 100% throw away the type information in Movable. I think that's what people were trying to accomplish by using typeof 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 T without 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 2 years ago

Contextual typing will set move's parameter to number; 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 2 years ago

@RyanCavanaugh Thanks. Gotcha. That seems like a good change then, almost independent of what happens with satisfies.

ethanresnick commented 2 years ago

For excess property checking... I honestly have no idea. But I'm a bit confused about how z could be an excess property in:

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

while start and stop would not be excess properties in:

const car = {
    start() { },
    move(d) {
        // d: number
    },
    stop() { }
} satisfies Moveable;
cefn commented 2 years ago

I wasn't expecting any Contextual Typing at all arising from satisfies and this was a surprise.

This would clutter my comprehension of the likely consequences of the operator. I was expecting satisfies to 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 satisfies would let me ask the compiler 'did I get it right' for some well-defined validation.

RyanCavanaugh commented 2 years ago

This 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, d would be an implicit any error, even though we know from the fact that you wrote satisfies Moveable that you want it to be number. Can you talk more about why that'd be desirable?

ethanresnick commented 2 years ago

This 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 satisfies can 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 -> green and b -> blue in the Color type, using "Rename Symbol" in my IDE, and have that rename all the properties in the color objects in Palette. Also, if I start adding a new green key to Pallete, 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.

fatcerberus commented 2 years ago

Up until recently I was in the "satisfies as 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 treat person.bob as boolean, as if there was no type annotation (they had worked around it with a cast to as 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 ApiResponse only affected contextual typing without acting as an upcast, it would be an elegant solution to this problem.

dead-claudia commented 2 years ago

@RyanCavanaugh I like the idea, but have you considered implements as the operator? (Though at the same time, it'd be worth verifying with TC39 whether that would run TS into trouble later.)

cefn commented 2 years ago

Hey, thanks @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 satisfies would validate typing, adding Contextual Typing might even somewhat defeat its purpose. Given the example you shared, I was expecting noImplicitAny to force me to be explicit about typing, with satisfies checking I got it right. I’d expect to be able to copy-paste the const car declaration somewhere else, knowing that I had satisfied Moveable.

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 satisfies if they want the compiler to help them with localised warnings. If satisfies doesn’t pass without errors, they’ve missed something and should fix it.

Explaining to them that satisfies does type-checking with no effect on types is helpfully simple and provides an answer to what they should do instead of as X. It means the author is still responsible to locally fulfil the type of everything in the normal way, and that satisfies does compile-time validation a bit like expect does 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 expect analogy 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 d through 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 requiring d to be typed even in the presence of satisfies.

SUMMARY

Based on a minimal model of what satisfies could do (it checks if something is satisfied) having it actually add types to d violates 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!

kasperpeulen commented 2 years ago

@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], then typeof e would become number[], but I would hope that:

btoo commented 2 years ago

@RyanCavanaugh I like the idea, but have you considered implements as the operator? (Though at the same time, it'd be worth verifying with TC39 whether that would run TS into trouble later.)

Resurfacing https://github.com/microsoft/TypeScript/issues/7481#issuecomment-787399912 here, though I believe my suggestion has been mentioned more in the past

RyanCavanaugh commented 2 years ago

@kasperpeulen good questions, and ones I should have put in the OP. Arrays contextually typed by a tuple type retain their tupleness, and numeric literals contextually typed by a union containing a matching numeric literal type retain their literal types, so these would behave exactly as hoped (assuming contextual typing is retained).

A neat trick for trying these out in existing syntax is to write

// Put the desired contextual type here
declare let ct: {a: unknown, b: 2}
// Initialize with assignment expression
const d = ct = {a: 1, b: 2};
// d: { a: number, b: 2}

Chained assignment is AFAIR the only place where you can capture the inferred type of a contextually-typed expression.

kasperpeulen commented 2 years ago

@RyanCavanaugh I have read the proposal a couple of times, and all makes sense to me, except this point:

Disallow excess properties (unless you write T & Record<string, unknown>)

One of your examples would also not work if you would disallow excess properties:

const car = {
  // TS2322: Type '{ start(): void; move(d: number): void; stop(): void; }' is not assignable to type 'Movable'.
  //   Object literal may only specify known properties, and 'start' does not exist in type 'Movable'.
  start() {},
  move(d) {
    // d: number
  },
  stop() {},
} satisfies Moveable;

There are two reason why disallowing excess properties doesn't feel right to me.

  1. I see satisfies as the implements keyword of classes, but then for object literals. For classes, it is all right to write this:
class Car implements Movable {
  start() {}
  move(d: number) {
  }
  stop() {}
}

For both satisfies and implements, it means that the object must be at least "Moveable", but it can be more, it can be a subtype. Having extra properties is one of the most common ways of becoming a subtype of Moveable, and it would be strange to disallow that.

  1. It is much more logical to disallow extra properties when using type annotations:
const car2: Moveable = {
  start() {},
  move(d) {
    // d: number
  },
  stop() {},
};

As even if car is a subtype of Moveable with extra properties. We can not use those extra properties, we can not even access them, so it is much more likely to be a typo:

// TS2339: Property 'start' does not exist on type 'Movable'.
car2.start();

However, when we only want to satisfy "Moveable", but also purposely wanting to be more than that, we can access those extra properties just fine:

// fine
car.start();

I think disallowing excess properties when using satisfies is more a linter kind of feature, than a typescript feature, as it is not really giving any extra compile time safety, even if you make a typo, it will error somewhere else, and if it doesn't, it won't break anything:

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

declare function foo(object: Record<Keys, number>);

// hey why do I get an error here, I thought b had a default, oh wait, I made a typo there
const bar = foo({...defaults, c: 1);

Also, you won't get autocompletion for d, so it is quite hard to make this typo with the current popular editors/IDE's.

jekh commented 2 years ago

Would it also be possible to use the proposed satisfies operator on function declarations? For example:

type DoubleNumberFn = (a: number) => number

function doubleNumber(num) {
  // both num and doubled are numbers instead of any
  const doubled = num * 2

  // TS2322 Type 'string' is not assignable to type 'number'.
  return doubled.toString()
} satisfies DoubleNumberFn

This would potentially address the feature request in https://github.com/microsoft/TypeScript/issues/22063

ackvf commented 2 years ago

My usecase is fairly simple.

Consider this possibly dangerous situation: image

By using as I get hints (which is what I wanted), but it is dangerous as I get no type safety - no warnings for missing or extra unknown keys.

My ideal solution would be using is.

getFirebaseRef().push({} is MyItem)

-> now I would get hints for object properties -> now I would get errors for missing and unknown properties

Which is practically equivalent to this:

const x: MyItem = { // Type '{}' is missing the following properties from type 'MyItem': another, id, param
  hi: 1, // Type '{ hi: number; }' is not assignable to type 'MyItem'. Object literal may only specify...
}
getFirebaseRef().push(x)

from https://github.com/microsoft/TypeScript/issues/7481#issuecomment-389439442

peabnuts123 commented 2 years ago

+1 to the sentiments repeated several times already that a satisfies operator (or alternatively is or implements etc.) should be mostly used for type checking and not for inferring types. To put this in terms stated by the original post, I think that T as the result type is what most people desire. I comment here to say that the first scenario cannot be moved out-of-scope, as I believe it is the primary desire among people in the community, and the reason for the original thread (#7481).

To that end, I think that many of the scenarios in the OP are off-topic. The original summary explicitly states that basically all scenarios for a T result could be satisfied with a type annotation e.g.

const a: Movable = { … };
// is the same as
const a = { … } satisfies Movable;

I think this is exactly right. There is no type operator or keyword that is equivalent to this and I think that's what is missing. The motivation for a satisfies operator is for scenarios in which you cannot reasonably (or do not want to) declare a variable, and for that reason I think many of the examples given in the OP are not relevant. I don't think a satisfies operator is useful for the scenario in which you are simply declaring a variable. I do agree that there are several other separate issues described in the OP but I don't think they are in the scope for such a satisfies operator. We may possibly be able to hit multiple birds with a stone here, but as far as I can see it, the first example is the primary use-case.

Let me provide some further examples, similar to what others have posted already:

interface Component {
  id: string;
  name: string;
  // … other properties
}
// GOAL: Have a master list of all our components and some associated info/config/whatever
//  Secondary goal: don't allow extra properties. I personally think it is a source of bugs.

// Attempt A: Index signature type annotation.
// Result: UNDESIRED. No type safety for accessing properties on `componentsA`
const componentsA: { [index: string]: Component } = {
  WebServer: { id: '0', name: "Web Server" },
  LoadBalancer: { id: '1', name: "Load Balancer" },
  Database: { id: '1', name: "Load Balancer", url: "https://google.com" }, // DESIRED type error. `url` is extra
};
console.log(componentsA.NotARealComponent); // UNDESIRED because no type error. `componentsA` allows dereferencing any property on it

// Attempt B: Using `as` operator
// Result: UNDESIRED. Missing or extraneous properties on components
const componentsB = {
  WebServer: { id: '0', name: "WebServer" } as Component,
  LoadBalancer: { id: '1' } as Component, // UNDESIRED because completely missing property `name` (NO IDEA why this compiles at-present)
  Database: { id: '1', name: "Load Balancer", url: "https://google.com" } as Component, // UNDESIRED because no type error for extraneous `url` property
};
console.log(componentsB.NotARealComponent); // DESIRED type error. No property `NotARealComponent`
console.log(componentsB.LoadBalancer.name); // UNDESIRED because `name` property does not even exist on `LoadBalancer`

// Attempt C: Using a Type<T>() function
// Result: DESIRED. But there is no way of doing this with the type system - must invoke runtime identity function
function Type<T>(obj: T): T { return obj; }
const componentsC = {
  WebServer: Type<Component>({ id: '0', name: "WebServer" }),
  LoadBalancer: Type<Component>({ id: '1' }), // DESIRED type error. Property `name` is missing.
  Database: Type<Component>({ id: '1', name: "Load Balancer", url: "https://google.com" }), // DESIRED type error. Property `url` is extra.
};
console.log(componentsC.NotARealComponent); // DESIRED type error. No property `NotARealComponent`

I desire an operator that is the equivalent of the Type<T>() function as above, and I believe many others do too. There are other examples where this comes up too, such as passing an object to a function:

// Common interface
interface ComponentQuery {
  name: string;
}
// For querying for databases specifically
interface DbComponentQuery extends ComponentQuery {
  type: "db";
  shardId: string;
}
// etc… presumably other specific queries too

// Query for a component or something, IDK.
// Would return a `Component` in the real world.
// Just a contrived example.
function queryForComponent(component: ComponentQuery): void { /* … */ }

// GOAL: Call `queryForComponent()` for a DB component

// Attempt A: No type casting
queryForComponent({
  type: "db", // UNDESIRED because type error that `type` does not exist on `ComponentQuery`
  name: "WebServer",
  shardId: "2",
});

// Attempt B: `as` keyword
queryForComponent({
  type: "db",
  name: "WebServer",
  // UNDESIRED: Missing `shardId` property - not at all useful
} as DbComponentQuery);

// Attempt C: Type<T>() function
function Type<T>(obj: T): T { return obj; }
// DESIRED. Will not work if any property is missing, extra, or incorrect type.
queryForComponent(Type<DbComponentQuery>({
  type: "db",
  name: "WebServer",
  shardId: "2",
}));

// Only working alternative. Declare variable just to pass into function.
// Not always possible in certain difficult scenarios.
const query: DbComponentQuery = {
  type: "db",
  name: "WebServer",
  shardId: "2",
};
queryForComponent(query);

Apologies for the long and rather in-depth examples. I hope they will be useful to clarify the need here, and allow others to simply +1 this instead of needing to provide further similar examples.

I understand that all the scenarios described in the OP are real problems faced by people, and may or may not need addressing, but I believe the behavior I have described here is desired by many, and I feel that the OP and the conversation within this thread are explicitly heading towards removing it from the scope. Even if this satisfies keyword is to fulfill all the scenarios except this one, then the other issue needs to be left open and not related to this one.

Lastly I will say, if your desires are described in this comment, I would ask you to 👍 it. It will help keep the thread tidy while also demonstrating how much the community desires this behavior.

ethanresnick commented 2 years ago

@peabnuts123 You say "T as the result type is what most people desire", but (politely) what's the evidence for that? By my read, the original thread (asking for safe upcast) turned into a discussion of all these other use cases because those use cases are actually more common/powerful.

I think your examples actually show why limiting satisfies to a safe upcast is not ideal: both of your examples can pretty easily be handled by a version of satisfies that returns typeof e with contextual typing, whereas handling the other use cases with simply a safe upcast is not possible. To demonstrate:

// This has all the same safety as your componentsC example,
// modulo some open questions about excess property checks. And it has less repetition. 
const componentsC = {
  WebServer: { id: '0', name: "WebServer" },
  LoadBalancer: { id: '1' } // satisfies rejects this for missing the name
  Database: { id: '1', name: "Load Balancer", url: "https://google.com" }, // might or might not reject the extra url
} satisfies Record<string, Component>;
// Again, same safety as your example, except for the open questions about excess property checks.
queryForComponent({
  type: "db",
  name: "WebServer",
  shardId: "2",
} satisfies DbComponentQuery);

I also wanna point out that the TS team realized that e satisfies T as T might be a way to do a safe upcast, even if satisfies returns typeof e. If so, I think there's really very little reason to limit satisfies by having it return typeof e.

peabnuts123 commented 2 years ago

@ethanresnick Thanks for responding to my examples and showing how the proposed logic could work to achieve those goals. I think you're right to put aside concerns around extraneous properties at this point too, as I get the sense that the community is divided on this matter. I included those concerns as relevant in my examples but I am happy to put them aside; I will say however that before anything like this lands in TypeScript, those concerns around extraneous properties need to be addressed and an informed decision made.

As for what evidence I have for my assumption, it seems clear to me that almost everybody in the original thread is asking for it. I have been following it for a while and perhaps in the middle of the thread the topic changes, but for example, from the first 10-20 replies there are many examples describing exactly the same thing, which to me appears to be out-of-scope for the proposal in this thread. Perhaps I am missing some nuance? I only know of up/down casting from strongly typed languages like Java and C# where an upcast is always safe and a downcast may lead to a runtime exception. TypeScript's semantics around upcasts and downcasts (might be missing properties) are somewhat mysterious to me (see "I don't know why this even compiles" comment in my previous example).

For clarity, my assumption is that most people seem to desire an operator essentially equivalent to:

function Is<T>(obj: T): T { return obj; }

I have realised that not explicitly passing <T> can lead to similar odd scenarios as the as operator, so I treat this proposed functionality as equivalent to always using the function like

const myThing = Is<Thing>({
  a: 2,
  b: 3,
});

where <Thing> is explicitly passed ALWAYS. Apologies for leaving that out of my previous post, I hadn't considered the danger of calling the function without it.

Here are examples of what I see as people asking for this functionality:

I feel personally that the use of such an operator in your first counter-example (satisfies Record<string, Component>) is undesirable. The suggested semantics do seem to work (aside from "extra" properties - a personal taste) for my examples but don't match my own internal semantics of the code I'm writing. Perhaps that is the difference in opinions here; indeed I can even see in the original thread @RyanCavanaugh early-on already proposing that the result type be typeof e or similar and people disagreeing. I don't think of this type check as forming around my entire object and then just returning whatever type is declared - I think of the type check as declaring the type i.e. the source of truth for what the type is (and expecting the compiler to enforce that). So declaring my whole object as a Record<string, Component> has IMO the side-effect of implying Component for each property (and then returning a "totally different" type), but I don't think of my master-list object as a Record<string, Component> nor am I even upcasting it to one.

Again I'd like to restate that I may be missing some nuance here as creating language and type systems is Hard™ so please forgive me if I'm missing the mark.

orta commented 2 years ago

Probably worth keeping in mind that people will probably ask for something like module.export satisfies XYZ (https://github.com/microsoft/TypeScript/issues/38511) building on this, which would be well used in the modern web ecosystem.

RyanCavanaugh commented 2 years ago

In a world where satisfies returns the expression type:

In a world where satisfies returns the asserted type:

One could make an argument that this means we need two operators, which, fine, but if you add satisfies as asserted-type, then you definitely need two operators and haven't solved most of the original use cases, whereas if you add satisfies as expression type, you've solved all the use cases via one syntax or another, and the second operator is just sugar for satisfies T as T

We also have to consider the relative frequency of things; if you have

const v: C = e;

or

function fn<T extends U>(arg: T): void;
fn<C>(e);

then you already have a place to write C that performs a safe upcast. There are certainly places you can't, or can't as easily, but it's definitely possible "a lot of the time" today. There are no syntactic positions today that behave like e satsifies T where that expression returns typeof e.

BenjaBobs commented 2 years ago

I like the idea of having the strictness and the "castness" be orthogonal. This way the intent can be quite clear.

For example, if we imagine the satisfies and is keywords from previous discussions, and use strict (or maybe another word) to modify the strictness like so:

Loose Strict
Without cast satisfies T strict satisfies T
With cast is T strict is T

Given a type like this

type T = {
    a: number;
    b: bool;
}

I would expect the following behaviour:

Loose without cast

// typeof data === { a: number, b: bool, c: string }
const data = {
    a: 1, // OK, gets auto complete
    b: true, // OK, gets auto complete
    c: "text", // OK
} satisfies T;

// Error: missing property 'a' of 'T'
const data = {
    b: true, // OK, gets auto complete
} satisfies T;

Strict without cast

// Error: 'T' doesn't have property 'c'
const data = {
    a: 1, // OK, gets auto complete
    b: true, // OK, gets auto complete
    c: "text", // Bad
} strict satisfies T;

// Error: missing property 'a' of 'T'
const data = {
    b: true, // OK, gets auto complete
} strict satisfies T;

// typeof data === { a: number, b: bool }
const data = {
    a: 1, // OK, gets auto complete
    b: true, // OK, gets auto complete
} strict satisfies T;

Loose with cast

// typeof data === T
const data = {
    a: 1, // OK, gets auto complete
    b: true, // OK, gets auto complete
    c: "text", // OK
} is T;

// Error: missing property 'a' of 'T'
const data = {
    b: true, // OK, gets auto complete
} is T;

Strict with cast

// Error: 'T' doesn't have property 'c'
const data = {
    a: 1, // OK, gets auto complete
    b: true, // OK, gets auto complete
    c: "text", // Bad
} strict is T;

// Error: missing property 'a' of 'T'
const data = {
    b: true, // OK, gets auto complete
} strict is T;

// typeof data === T
const data = {
    a: 1, // OK, gets auto complete
    b: true, // OK, gets auto complete
} strict is T;

I know that this adds a lot of extra word reservation, and I'm not sure if that's against the wishes of the language design, but I think it conveys intent very clearly.

Jack-Works commented 2 years ago

Happy to see this feature! I found it can be very useful if we can use it on a Function declaration. It can close issue https://github.com/microsoft/TypeScript/issues/40378

Jack-Works commented 2 years ago

@RyanCavanaugh I like the idea, but have you considered implements as the operator? (Though at the same time, it'd be worth verifying with TC39 whether that would run TS into trouble later.)

I agree, or asserts can also be an option

peabnuts123 commented 2 years ago

I won't continue to post long rants in this thread (thanks for bearing with me) as I've made my points and they have been understood. Last thing I will say for now is to consider whether e satisfies T as T is still an acceptable compromise if I were to be shown correct in my assertion that most people are looking for this behaviour (i.e. most people think satisfies should return T). This would be introducing an operator to facilitate a behaviour but without supplying it directly. Just because it can facilitate 5 use-cases instead of 1, doesn't speak at all to the relative frequency of those use-cases. Often times as developers we spend considering the "what ifs" of a problem but the experienced among us know we need to be pragmatic about it to avoid wasting time or over-engineering things.

Reading e satisfies T as T seems pretty obtuse to me, and I would be sad to see it land as The Way™ to do this, just to enable some other use-cases (even though they might be esoteric in comparison). Maybe all those other use-cases are much more important than I think; I've just never seen them, and I don't see many people in the original thread asking for them.

cefn commented 2 years ago

@peabnuts123 I was discussing a case where inferring typeof e and not inferring T was the correct behaviour for satisfies yesterday if you want to take a look. Quoting the author...

If I take off the readonly ExemptPayeeCode[] type from my constant arrays, it works, but I'd rather not because I want to ensure I'm not fat-fingering one of the payee codes in my array

I don't think the case is esoteric at all and I encounter it routinely. It is due to wanting inference from the contents of an as const declaration, while also validating those contents. The inference is obliterated by broadening its type. But broadening its type by assigning to that type is the only (erasure-compatible) way to check its type currently. So checking without broadening is central to this proposal for me. The author wanted type-checking (a reasonable expectation given what Typescript can reason about) AND inference (also a reasonable expectation).

The only workarounds I know of have runtime implications (cannot currently benefit from erasure), as sketched in https://github.com/colinhacks/zod/issues/831#issuecomment-1054814462

For my particular usage I expect my codebase to be strewn with satisfies T and almost never satisfies T as T.

kasperpeulen commented 2 years ago

@peabnuts123 In the last 4 years, I have had many use cases, in storybook there is an open issue, that can only be solved if the operator works as suggested in the opening post, but not with your suggestion:

https://github.com/storybookjs/storybook/issues/13747#issuecomment-1008705531

Basically, those are cases where you do want autocompletion, but also want the information of the actual inferred type (just as the examples in the opening post):

const meta = { 
  component: MyComponent, 
  args: {
    a: "default" // auto completion for the props because of satisfies
  } 
} satisfies Meta<Props>;

// meta is now inferred for only having prop "a" specified, as the type is typeof meta, and not Meta<Props>
// therefore we can have great type safety below (which the current version of storybook lacks)
export const MyStory1: Story<typeof meta> = { args: { b: "goodbye" } }; // Fine, a has default
export const MyStory2: Story<typeof meta> = { args: { a: "hello hello" } }; // Error: Property 'b' is missing in type '{ a: string; }'
export const MyStory3: Story<typeof meta> = { args: { a: "hello hello", b: "goodbye" } }; // Fine, can overwrite a

An other example from a real project. We needed to mock messy and legacy responses of some http service, so to make this mocking bearable we wanted some good defaults and a factory function.

Now it is quite hard to get here auto completion, and type safety as well.

interface DeliverySchedulesForProduct {} // auto generated type from a http service by swagger

// auto completion because of the annotation
export const defaultDeliverySchedulesForProduct: Partial<DeliverySchedulesForProduct> = {
  globalId: 0,
  offerId: 0,
  warehouseCode: 'CC',
  warehouseLocation: 'VV',
  transporterCode: 'TTT',
};

// also auto completion, but we miss type safety
// 1. typescript can't guarantee we provided everything needed
// 2. we may provide too much of things that already have a default, or is really optional
export function createDeliverySchedulesForProduct(deliverySchedulesForProduct: Partial<DeliverySchedulesForProduct>) {
  return {
    ...defaultDeliverySchedulesForProduct,
    ...deliverySchedulesForProduct,
  };
}

So instead we used this:

// no auto completion, which sucks
export const defaultDeliverySchedulesForProduct = {
  globalId: 0,
  offerId: 0,
  warehouseCode: 'CC',
  warehouseLocation: 'VV',
  transporterCode: 'TTT',
};

// but we now have really good type safety here
export function createDeliverySchedulesForProduct(
  deliverySchedulesForProduct: OptionalKeys<DeliverySchedulesForProduct, keyof typeof defaultDeliverySchedulesForProduct>,
) {
  return {
    ...defaultDeliverySchedulesForProduct,
    ...deliverySchedulesForProduct,
  };
}

export type OptionalKeys<T extends Record<string, unknown>, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>;

Of course, the ideal solution would be to have a satisfies operator, that infers the type, but also gives auto completion:

export const defaultDeliverySchedulesForProduct = {
  globalId: 0,
  offerId: 0,
  warehouseCode: 'CC',
  warehouseLocation: 'VV',
  transporterCode: 'TTT',
} satisfies Partial<DeliverySchedulesForProduct>;

I have encountered many examples like those in the years, in fact, I sometimes now use this function:

/**
 * A sound alternative for as, that infers the type of x, and checks if it really is a subtype of A.
 * Redundant when the satisfies operator will land in Typescript:
 * https://github.com/microsoft/TypeScript/issues/7481
 * */
export function as<A>() {
  return <T extends A>(x: T) => x;
}

So that I can write:

const meta = as<Meta<Props>>()({ 
  component: MyComponent, 
  args: {
    a: "default"
  } 
});
tjjfvi commented 2 years ago

@jtlapp Both of those issues are likely resolvable without satisfies; if you post a question on the TypeScript Discord server, someone will likely be able to help you 🙂

tjjfvi commented 2 years ago

@jtlapp Though I have not looked too deeply, I believe there is a solution to each of your issues. You can certainly post on StackOverflow, though I don't think it's a great resource for such TS problems. Again, I recommend posting a question on the TS Discord; someone there will likely be able to help you, and I can take a look there later today. Either way, I think it would be best if this conversation moved out of this thread, lest we derail the discussion any further 🙂

tjjfvi commented 2 years ago

@jtlapp I'm not suggesting you delete your original post; all I'm saying is "we might be able to help you on the TS Discord server". I'm sorry that you feel I'm chasing you off; that was not my intention.

jtlapp commented 2 years ago

@tjjfvi, and you were right. I was so close. All I needed to do was class MyApi implements ElectronMainApi<MyApi>. It never occurred to me that I could pass the class itself in as the generic type.

I'm so sorry. I'm not sure why this wasn't pointed out to me in the prior thread. Thank you so much! I'll delete my off-topic posts.

kaya3 commented 2 years ago

To add some anecdotal evidence to this discussion, there are quite a few answers I've written on Stack Overflow using trivial generic functions, which would have been obviated if there was a satisfies operator returning the expression type:

I don't think I've answered any where a satisfies operator returning the asserted type would have been more convenient.

RyanCavanaugh commented 2 years ago

Here's a Playground build that walks through some of the use cases discussed above

ProdigySim commented 2 years ago

This is maybe a nitpick (this functionality looks great) but I think it would be better aesthetics if this "satisfies" constraint could be written where the type annotation normally is, or at least it would be nice if the error squiggles could be shown up there. For very large objects, having to scroll to the bottom to identify errors is a little unintuitive.

Compare to the current alternatives, where the error squiggles appear closer to the definition of the variable: TS Playground

Personally something like const myProps: satisfies TableProps = { ... } would sound nice to me, but I'm guessing this would be much more problematic for the language. In my head, I think "I want this variable to be something that is assignable to TableProps" more than "I want this object to be something that is assignable to TableProps". I struggle to think of use cases for this operator that are not in the context of variable initialization. But I suppose the same is true of as const.

In any case, being able to hoist the error squigglies up to the variable name / assignment would be helpful.

mon-jai commented 2 years ago

This could fix #40378 too by making the following code valid.

type Foo = (arg: number) => number

function Bar(arg: number) {
    return arg
} satisfies Foo

Bar()
mon-jai commented 2 years ago

Could this fix #10421 too?

ackvf commented 2 years ago

I hope that satisfies is still just a placeholder and not the final keyword. It's inconvenient to type as it's left hand-heavy. I feel like breaking my fingers while trying to type it for the tenth time. 😄

👎 satisfies 🚀 is, be 🎉 is as, be as <-- dual word ftw 👀 implements, asserts 😄 something else

const somePropsOfKeys = {
    a: 0,
    b: "hello",
} satisfies Partial<Record<Keys, unknown>>;

const somePropsOfKeys = {
    a: 0,
    b: "hello",
} is Partial<Record<Keys, unknown>>;

const somePropsOfKeys = {
    a: 0,
    b: "hello",
} be Partial<Record<Keys, unknown>>;

const somePropsOfKeys = {
    a: 0,
    b: "hello",
} is as Partial<Record<Keys, unknown>>;
P-Daddy commented 2 years ago

I hope that satisfies is still just a placeholder and not the final keyword. It's inconvenient to type as it's left hand-heavy. I feel like breaking my fingers while trying to type it for the tenth time. :smile:

I'd also say that the postfix syntax is a little awkward, because I find myself typing this first:

const something = {

} satisfies Record<string, SomeType>;

... and then going back up to fill it in. That way I get autocomplete.

const something = {
    foo: {
        // I'll get autocomplete here for the property names
        someProp: ... // and their values, when applicable
    }
} satisfies Record<string, SomeType>;

Perhaps more bothersome, though, is that it's likely that the definition of something spans many lines, and when reading code like this, I would have to visually scan (and perhaps scroll) past them all to get to the bottom of the definition in order to find out what the declared type is. That would give me the mental context to understand what the intention is behind the data, and then I'd have to scan (and perhaps scroll) back to the top of it in order to start reading it.

I propose syntax similar to this:

const something as Record<string, SomeType> = {
    foo: {
        someProp: ...
    },
    ...
};

To me, that's more intuitive and more convenient. As an added bonus, it reuses an existing contextual keyword instead of adding a new one, and it uses it for a similar purpose as its existing postfix one.

RyanCavanaugh commented 2 years ago

Based on feedback from the last design meeting, I've been spinning tighter and tighter epicycles trying to reason about the relationship between the outer contextual type and the asserted type. The original motivating example for combining these types was something like this:

const a: Record<string, (a: number) => void> = {
    m: x => x,
    n: x => x
} satisfies Record<"m" | "n", unknown>;

If we disregard the outer contextual type Record<string, (a: number) => void>, then both x parameters get an implicit any, which is super unfortunate because it's pretty darn obvious what they ought to be. The initial naive idea was to combine the outer and asserted types with intersection, which solves many similar-looking setups where the key type isn't string. But intersection is not a sufficient operator to solve this problem in general (!):

const b: Record<"m" | "n", unknown> & Record<string, (a: number) => void> = {
    // still implicit anys
    m: x => x,
    n: x => x
};

What you want here is something more like a spread of the outer type and asserted type, with the asserted type taking precedence, except it's actually a recursive spread where you combine types that seem combinable and overwrite types that don't. We don't have any type operator like that and don't have any other scenarios to motivate producing such an operator.

You can also produce some weirdness that feels pretty uncomfortable:

const a: { x?: (n: number) => void; q?: unknown; } = {
    x: n => n
} satisfies { q?: unknown };

Here, n is contextually typed despite its containing property x being marked excess. That's sort of unprecedented but maybe just matches one of the use cases outlined in the OP and is only weird because I constructed it to look weird, not sure?

My inclination at this point is to simply remove the effect of the outer contextual type entirely. The real-world use cases don't seem to strictly require it, and in general we haven't found making contextual types better to be a meaningfully-breaking change, so the door to "fix" it later would remain open.

Akxe commented 2 years ago

Hi, this might be too late to the party but I really wanted to use the satisfies keyword according to the examples below:

Current:

/** The next developer might unknowingly of consequences add a number to the list breaking something down the line */
const list1 = ['one', 'two', 'three'] as const;
// switch (list1) options are 'one' | 'two' | 'three';

/** Here, next developer will know that the values are expected to be string, but the `as const` is basically dropped */
const list2: readonly string[] = ['one', 'two', 'three'] as const;
// switch (list2) options are string;

Hoped for:

/** Here, next developer will know that the values are expected to be string, while the `as const` actually dictates the type */
const list3 satisfy string[] = ['one', 'two', 'three'] as const;
// switch (list3) options are 'one' | 'two' | 'three';

I do not care about where the satisfies will be, but since it can be used for hinting at key/value pairs of the assigned object, it would make sense to have it at the beginning. Same problem as import syntax.

ackvf commented 2 years ago

I am convinced, postfix syntax does indeed seem awkward for variable declaration statements, but it is still necessary to have it for inline expressions. Now the latter can obviously be used during variable declaration too, so a second way to write it is purely cosmetic?

ASDFGerte commented 2 years ago

Similar to kasperpeulen, i use the following two helper functions for some of the mentioned use-cases:

const specifyPartialType = <T,>() => <U extends T>(x: U): U => x;
const widenType =          <T,>() => <U extends T>(x: U): T => x;

// e.g.
const SOME_ARRAY_PROPERTIES = specifyPartialType<readonly (keyof any[])[]>()([
  'push',
  'pop',
  // 'other' // would error
] as const);

const o = {
  // many other properties...
  myArray: widenType<number[]>()([]),
  maybe:   widenType<{ something: number } | null>()(null),
};

Towards additional use-cases, producing errors with impossible generic constraints has access to the inferred type, therefore can assert binary conditions, which would otherwise be hard or impossible to express (at least from my limited knowledge). As an example, checking uniqueness (which could be chained with specifyPartialType):

type IsFiniteTuple<T extends readonly any[]> = number extends T['length'] ? false : true;
type FiniteTupleHasUniqueElementTypes<T extends readonly any[], Acc = never> = T extends readonly [infer Head, ...infer Tail]
  ? Head extends Acc
    ? false
    : FiniteTupleHasUniqueElementTypes<Tail, Acc | Head>
  : true;
type AND<T extends true | false, U extends true | false> = T extends true ? U extends true ? true : false : false;
type HasUniqueElementTypes<T extends readonly any[]> = AND<IsFiniteTuple<T>, FiniteTupleHasUniqueElementTypes<T>> extends true
  ? T
  : 'Element types are not unique.';

const uniqueLiterals = <T extends readonly any[]>(arr: HasUniqueElementTypes<T>) => arr;
const arr = uniqueLiterals([
  'a',
  'b',
  // 'b' // would error
] as const);

As an honorable mention, another existing method of providing such guarantees are type-only assertions (usefulness and location of errors is imho slightly worse, and cannot be used on anything anonymous, but do fully erase):

type AssignableTo<T, U> = T extends U ? true : 'T does not extend U';
type Is<T, U> = AssignableTo<T, U> extends true
  ? AssignableTo<U, T> extends true
    ? true
    : 'U does not extend T'
  : 'T does not extend U';
type Assert<T extends true> = T;

// slightly different signature
type HasUniqueElementTypes2<T extends readonly any[]> = AND<IsFiniteTuple<T>, FiniteTupleHasUniqueElementTypes<T>> extends true
  ? true
  : 'Element types are not unique.';

const arr = ['a', 'b', 'b'] as const;
type Check = Assert<HasUniqueElementTypes2<typeof arr>>; // errors, not unique
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
mpawelski commented 2 years ago

I've just read up the discussion about satisfies since the last time I looked at it (before this comment). I see a lot of people want and expect different things, and I am no different 😉

So here's my proposition how the "satisfies" operator should work, but also some ideas about other feature that I think people mentioned along the big discussion here and in #7481

My wish list

  1. : operator for contextually typing an expression.
  2. implements operator that works similar to implements keyword for classes but it works for expressions
  3. (optional - i.e I'm not sure about it ) satisfies operator that does the same as implements but with excess checks
  4. think about improving inference for tuple literals (already existing issue #24350)

1. : operator

This was already mentioned by @peabnuts123 but I also want to stress out that while all the scenarios described by @RyanCavanaugh are useful I think we forgot that at the beginning people wanted just the possibility to contextually type an expression without writing additional function call like asType<SomeType>({ ... }) or introducing temporary variables. Later the discussion somehow diverted to the current satisfies discussion.

I think if we only get satisfies operator then in the real world 90% of it's usage could be replaced by contextual typing but people didn't wrote it because they didn't want to write additional function calls or intermediate variables.

In other words most people want such code

type Type = {
    a: string,
    b: number,
}
const obj = {
    // ... image this is very big object literal
    // ... and this property is just a part of it
    prop1: {a : "aaa"} as Type
    // ...
    // ... other properties
}

to give them an error when they forget about the property ("b" here) but they still want the prop1 to be the type of Type.

If they learn that they could write

const obj = {
    // ... image this is very big object literal
    // ... and this property is just s part of it
    prop1: {a : "aaa"} satisfies Type
    // ...
    // ... other properties
}

To get this compiler verification they will use satisfies operator without realizing that the property is not the type of Type. Or they will write prop1: {a : "aaa"} satisfies Type as Type which is quite ugly syntax (comparing to possible prop1: ({a : "aaa"} : Type) Flow-like syntax that I and others mentioned).


We also have to consider the relative frequency of things; if you have

const v: C = e;

or

 function fn<T extends U>(arg: T): void;
 fn<C>(e);

then you already have a place to write C that performs a safe upcast. There are certainly places you can't, or can't as easily, but it's definitely possible "a lot of the time" today. There are no syntactic positions today that behave like e satsifies T where that expression returns typeof e.

I think in previous thread you (@RyanCavanaugh) also said something like "you can have contextual typing today, just type it in variable declaration or use helper function, let's discuss another operator that allows us to do something that we can't do today".

Yet, there are still many people that doesn't like the status quo. This was well described here (kudos @peabnuts123). This recent comment also got many upvotes which shows that this is something people miss in Typescript.

For me it's obvious that people really want the "(:) contextually typing" equivalent for expressions and if we get it plus also get satisfies operator then I'm pretty sure : would be used much more often and satisfies would become a "useful but not that common advanced feature".


In a world where satisfies returns the expression type:

  • If you want to "safe upcast", write e satisfies T as T

  • If you want any of the other scenarios listed here, you write e satisfies T

In a world where satisfies returns the asserted type:

  • If you want to "safe upcast", write e satisfies T

  • If you want any of the other scenarios listed here, you can't

One could make an argument that this means we need two operators, which, fine, but if you add satisfies as asserted-type, then you definitely need two operators and haven't solved most of the original use cases, whereas if you add satisfies as expression type, you've solved all the use cases via one syntax or another, and the second operator is just sugar for satisfies T as T

@RyanCavanaugh and I think (based on all the current feedback in this and previous thread) that e satisfies T as T is such a common thing that people want that it's worth to have a separate operator for it (preferably ":" like in Flow, as people are familiar with what : currently does)

2. satisfies/implements operator semantics

I admit I was at first sceptical about the usefulness of the new operator. But since then I changes my mind and actually wish there was something like what we discuss now. But my mental model for this new operator is more like:

something like implements on classes but on expression level.

And I see I'm not the only one. Maybe we can rename satisfies to implements to make it easier to explain what this operator is capable of (someone already proposed it).

For me the new satisfies operator mostly achieves what implements does on classes today, which is:

triggering possible compiler errors earlier, at the place when I define class, not later where I use the instance and pass it further.

For example this currently working code with class and implements:


interface FooBar {
  foo: string;
  bar: () => number;
}

class TestClass /* implements FooBar */ {
  // foo = "aaa";
  bar() {
    return 1;
  }

  otherProp1 = 1;
  otherMethod(arg: number) {
    return arg;
  }
}

function processFooBar(fooBar: FooBar) {}

let test1 = new TestClass();
processFooBar(test1); //Error here, missing "foo" propery. But I want to get it earlier where I write TestClass. No problem! Just uncomment "implements FooBar"!

can be thought as an equivalent to this code with new proposed satisfies operator (here I named it implements):

let test2 = {
  bar() {
    return 1;
  },
  otherPro1: 1,
  otherMethod(arg: number) {
    return arg;
  },
} /* implements FooBar*/;

processFooBar(test1); //Error here, missing "foo" propery. But I want to get it earlier where I write "test2". No problem! Just uncomment "implements FooBar"!

2.1 Contextual typing in implements operator

I agree that such behaviour would be very useful:

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

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

and I expected that it works like that for classes today and was surprised that it didn't 😢:

class Car implements Movable {
  start() { }
  move(d) { //Error: Parameter 'd' implicitly has an 'any' type.
  }
  stop() { }
}

Providing this "contextual typing" behaviour for implements in classes would be a valuable change in itself.

3. excess checks

As I mentioned I don't expect the imlements operator to do excess checking but I see there are some scenarios where it's desirable behaviour. Maybe if we rename the current proposed operator to implements then we could add separate satisfies operator that is: "same as implements but with excess checking"?

This is just a loose thought. I'm not entirely convinces excess checking is that valuable, as someone mentioned , this could be handled by linters, but the bigger reason I don't want excess checks is that it breaks my "like implements on classes" mental-model of the new operator.

4. better tuple inference

This is completely different and well known issue where people want the array literals to be inferred as tuples instead of array.

But I got a feeling that a lot of people would really like to simplify the typings for tuples and they somehow want the satisfies to do that for them even though the general improvements to tuple inference would be preferred.

If let tup = [1, 2, 3] would be inferred as [number, number, number] then let tup = [1,2,3] implements number[] would also be inferred as [number, number, number] with the compiler ensuring that all tuple elements are numbers. And I see this is one of scenario that people want.

So I suggest looking at #24350 once again in foreseeable future.

Am I asking for too much?

At first it looks that I ask for a lot of new features for TS. That it's too much new stuff that programmer will need to learn, and it makes TS a more complicated language.

But I think I ask for something that already exists in the language (current : and implements keyword) but not on an expression level. There aren't much new concepts too learn because everyone knows what : and implements currently do.

That's why I'm even a bit hesitant to have excess checking. satisfies operator without excess check is just "an implements on expression level", nothing new. Introducing excess checking is actually breaking this clear "implements but for expression" mental model of the new operator.

c-harding commented 2 years ago

@mpawelski I would agree with your points.

: operator

I was initially concerned about the possible conflict with shorthand object property syntax ({ object } would become { object: object: type }), but this is unlikely to be a problem because the annotation is only useful for literals: in other cases the type could be annotated where the variable object is declared. This is the feature that I was originally looking for when I came across this discussion, and excess checks would make sense here.

satisfies/implements

For me, the implements mental model is the most helpful for this feature, so I would be in favour of reusing this contextual keyword. In this case, excess checks would not make sense.

mpawelski commented 2 years ago

@c-harding Flow requires parentheses to avoid ambiguity with other syntax and it looks that it doesn't support : type casting for object shorthand syntax like { object }.

In your example you would really have to write { object: (object: type) }.

But, as you say, I also don't think it's a big deal. In most cased you should be able to annotate where object is declared.

shicks commented 2 years ago

Forgive my interjection here, but I'm a bit confused as to why this is important? It's been established that this functionality is easily achievable today using a few trivial functions. There was mention of "runtime impact", but anyone who actually cares about runtime impact (either code size or VM performance) is going to be using some sort of optimizer, and inlining identity functions is easy enough for any optimizer to handle, and addresses both concerns.

phryneas commented 2 years ago

@shicks "easy functions" are nice, but often that "extra function call to simulate a specific TS behaviour" is not very desirable, especially in library situations.

Take Redux Toolkit - right now, we have two options:

createSlice({
  initialState: satisfies<StateType>({ state: "foo" } ),
  // other things that need `createSlice` generic arguments to be inferred
})

The first means we need to teach JS users of our library to write different runtime code than we need to teach TS users. This is bad from a teaching perspective and also not very consistent with "TS is just JS with types".

The second might "cast too hard" and hide errors

We would be super-happy about something like

createSlice({
  initialState: { state: "foo" } satisfies StateType,
  // other things that need `createSlice` generic arguments to be inferred
})

in the future.