Description
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 ofT
orT & typeof e
Does this seem right? What did I miss?