Skip to content

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

Closed
@RyanCavanaugh

Description

@RyanCavanaugh

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:

  • typeof e
  • T
  • T & typeof e

*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:

  • Contextually type empty arrays
  • Disallow excess properties (unless you write T & Record<string, unknown>)
  • Use typeof e as the expression type instead of T or T & typeof e

Does this seem right? What did I miss?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fix AvailableA PR has been opened for this issueFixedA PR has been merged for this issueSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions