-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Operator to ensure an expression is contextually typed by, and satisfies, some type #7481
Comments
Can you post a few examples of how you'd like this to work so we can understand the use cases? |
One example very close to the real-world need (I'm trying to get react and react-redux typings to correctly represent the required/provided Props): import { Component } from "react";
import { connect } from "react-redux";
// the proposed operator, implemented as a generic function
function asType<T>(value: T) {
return value;
};
// in real life, imported from another (actions) module
function selectSomething(id: string): Promise<void> {
// ...
return null;
}
interface MyComponentActions {
selectSomething(id: string): void;
}
class MyComponent extends Component<MyComponentActions, void> {
render() {
return null;
}
}
// I've changed the connect() typing from DefinitelyTyped to the following:
// export function connect<P, A>(mapStateToProps?: MapStateToProps,
// mapDispatchToProps?: MapDispatchToPropsFunction|A,
// mergeProps?: MergeProps,
// options?: Options): ComponentConstructDecorator<P & A>;
// fails with "Argument of type 'typeof MyComponent' not assignable" because of
// void/Promise<void> mismatch - type inference needs help to upcast the expression
// to the right interface so it matches MyComponent
export const ConnectedPlain = connect(undefined, {
selectSomething,
})(MyComponent);
// erronously accepted, the intention was to provide all required actions
export const ConnectedAs = connect(undefined, {
} as MyComponentActions)(MyComponent);
// verbose, namespace pollution
const actions: MyComponentActions = {
selectSomething,
};
export const ConnectedVariable = connect(undefined, actions)(MyComponent);
// using asType<T>(), a bit verbose, runtime overhead, but otherwise correctly verifies the
// expression is compatible with the type
export const ConnectedAsType = connect(undefined, asType<MyComponentActions>({
selectSomething,
}))(MyComponent);
// using the proposed operator, equivalent to asType, does not compile yet
export const ConnectedOperator = connect(undefined, {
selectSomething,
} is MyComponentActions)(MyComponent); I've called the proposed operator in the last snippet The other kind of scenario is complex expressions where it's not immediately obvious what the type of the expression is and helps the reader understand the code, and the writer to get better error messages by validating the subexpression types individually. This is especially useful in cases of functional arrow function expressions. A somewhat contrived example (using the tentative const incompleteTasks = (tasks: Task[]) => tasks.filter(task => !(getWork(task.currentAssignment) is Todo).isComplete); |
I ran into something similar when I was patching up code in DefinitelyTyped - to get around checking for excess object literal assignment, you have to assert its type, but that can be a little extreme in some circumstances, and hides potential issues you might run into during a refactoring. There are also scenarios where I want to "bless" an expression with a contextual type, but I don't want a full blown type assertion for the reasons listed above. For instance, if a library defines a type alias for its callback type, I want to contextually type my callback, but I _don't_ want to use a type assertion. In other words, a type assertion is for saying "I know what I'm going to do, leave me a alone." This is more for "I'm pretty sure this should be okay, but please back me up on this TypeScript". |
Sounds a lot like #2876? |
If I understand #2876 correctly, it's still a downcast (i.e. bypassing type safety). What I was proposing here is an upcast (i.e. guaranteed to succeed at runtime or results in compile time error). Also, while I think the best example of this operator exists in the Coq language: Definition id {T} (x: T) := x.
Definition id_nat x := id (x : nat).
Check id_nat.
id_nat
: nat -> nat Here, the expression |
Another case for this is when returning an object literal from a function that has a type union for it's return type such as interface FeatureCollection {
type: 'FeatureCollection'
features: any[];
}
fetch(data)
.then(response => response.json())
.then(results => ({ type: 'FeatureCollection', features: results })); This gets quite tricky for intellisense in VS because the return type from Also the error messages when the return value is invalid are quite obtuse because they refer to the union type. Knowing the intended type would allow the compiler to produce a more specific error. |
@chilversc I'm not sure how an upcast can help with your example. Could you show how it would be used, using the above asType function (which is the equivalent to the operator I'm proposing). Note that due to parameter bivariance, the current compiler would not always give an error on invalid cast. |
Odd, I thought I had a case where an assignment such as The cast was required to get the object literal to behave correctly as in this case: interface Foo {
type: 'Foo',
id: number;
}
let foo: Foo = { type: 'Foo', id: 5 };
let ids = [1, 2, 3];
//Error TS2322 Type '{ type: string; id: number; }[]' is not assignable to type 'Foo[]'.
//Type '{ type: string; id: number; }' is not assignable to type 'Foo'.
//Types of property 'type' are incompatible.
//Type 'string' is not assignable to type '"Foo"'.
let foosWithError: Foo[] = ids.map(id => ({ type: 'Foo', id: id }));
let foosNoErrorCast: Foo[] = ids.map(id => ({ type: 'Foo', id: id } as Foo));
let foosNoErrorAssignment: Foo[] = ids.map(id => {
let f: Foo = {type: 'Foo', id: id};
return f;
}); |
Could we just use interface A {
a: string
}
let b = {a: 'test'} as A // type: A, OK
let c = {a: 'test', b:'test'} as A // type: A, OK
let d = {a: 'test'} is A // type: A, OK
let e = {a: 'test', b:'test'} is A // error, b does not exist in A |
@wallverb that is really clever and really intuitive. Interestingly, it also provides a manifest way of describing the difference between the assignability between fresh object literals target typed by an argument vs existing objects that conform to the type of that argument. |
I like this idea of effectively a static type assertion. |
@normalser your example about |
Also linking #13788 about the current behavior of the type assertion operator |
I'm new to TypeScript but have already had a need for something like this. I've been Googling for a while now and looked in the TypeScript docs and can't find what I need. I'm really surprised since I assume this capability MUST exist. I think I ran into the problem trying to use Redux and wanted to ensure that I was passing a specific type of object to the connect function. Once I specify the type hint - "this is supposed to be a X" - I'd like the editor to do intellisense and show me what properties need to be filled in, so maybe the type name needs to come first like a safe cast expression.
|
@DanielRosenwasser I feel like there must be some way at this point to use mapped types or something to write something that breaks the comparability relationship in a way that it only allows subtypes through? |
Actually this would work for me. I just need to create a custom function that I use wherever I need an exact type. Seems kind of obvious in retrospect. I'm still surprised this kind of thing isn't built-in.
|
I am also looking for a way to specify the type of an inline object I am working with, so that when I By using There already are type guards that use the function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
} It would be nice to also have this: getFirebaseRef().push({} is MyItem) -> now I would get hints for object properties |
If the type of function createThing(str: string) {
return {
foo: [str] satisfies [string],
}
}
createThing('foo').foo[1].toUpperCase()
// ^ Should not be allowed to access index 1 of [string]
Yes please. Guarding against excess properties (which are often typos, leftovers from old APIs or other mistakes) is a common need and being able to do it inside arbitrary expressions would be very handy. Basically I was hoping just expression-level
Seems weird to me. |
I hope that's what this feature means - but a good scenario is combining |
@jtlapp If I understand your use-case correctly, you can already do something like type ApiTemplate = {
[funcName: string]: {
call: (data: any) => void;
handler: (data: any) => void;
}
};
const apiConfig1 = {
func1: {
call: (count: number) => console.log("count", count),
handler: (data: any) => console.log(data),
},
func2: {
call: (name: string) => console.log(name),
handler: (data: any) => console.log("string", data),
},
}
// Currently working way to do type validation without any runtime impact
const _typecheck: ApiTemplate = null! as typeof apiConfig1
// TS can still autocomplete properties func1 and func2 here,
// and errors for missing "func". apiConfig1 is not merely "ApiTemplate".
apiConfig1.func The unfortunate thing about that approach is that it involves using an extra unused variable, which tends to make linters mad. // Assert that apiConfig1 is a valid ApiTemplate
null! as typeof apiConfig1 satisfies ApiTemplate |
Thank you, @noppa. I actually do have my code working, but it's missing an important piece, which it seems that I didn't overtly say in my use cases above: I want VSCode highlighting the errors in the user's custom ApiTemplate definition, rather than at a later point in the code where the user passes that custom definition to some function. The goal is to facilitate correct API development at the point where the API code is written. I'm close to sharing my project to provide better clarity on the problem I'd like to solve. I just need to document it. I've found the Electron IPC mechanism so-many-ways error prone, and I've developed a way to create IPCs merely by creating classes of asynchronous methods, registering these classes on one end, and binding to them on the other. The entire objective is to make life as easy and bug-free as possible for the developer. I'm actually pretty amazed that I was able to do it. The only thing lacking at this point is having VSCode indicate API signature errors in the API code itself. I used to think the TypeScript type system was excessively complicated, but now I'm addicted to its power. |
The only way satisfies is useful to me is if it does provide additional context to the inferred type. Use case: providing more accurate types for object literal properties. This is particularly useful in something like Vue Options API where everything is declared in an object literal. |
@RyanCavanaugh I took a look at your questions above, and these are my thoughts: Example 1
In this case, I'd expect the type of
Example 2
This is a tricky one. At first I was going to make the argument that this case should raise an excess property error specifically because this code does: const s: Disposable = {
init() { ... },
dispose() { ... }
}; However, I realised that such behaviour would contradict my mental model of the operator. I mentioned above that my mental model of const value1 = (3 as const) satisfies number; // `value1` should be of type `3`
const value2 = ([1, 2, 3] as [1, 2, 3]) satisfies number[]; // `value2` should be of type `[1, 2, 3]` So, revisiting the example from the question with this in mind: type Disposable = {
dispose(): void;
beforeDispose?(): void;
};
const s = {
init() { ... },
dispose() { ... }
} satisfies Disposable; I would expect the type of {
init(): void;
dispose(): void;
} and because the type of Example 3
I feel like this should not be an error. I hope that helps! 🙂 Also, please let me know if my mental model of |
Right now there are no wrong answers -- the point of the exercise to determine what the "right" mental model of this operator should be! |
I would expect something to exactly like inference for generic constrained arguments, if that helps. If there's an obviously wrong behavior for one then it's probably also wrong for the other. |
@simonbuchan I agree. I guess that you could model the spec to this function: export function satisfies<A>() {
return <T extends A>(x: T) => x;
} If we then look @RyanCavanaugh examples, then it corresponds with @treybrisbane mental model: /* ~~ Example 1 ~~ */
const x = satisfies<[number, number]>()([1, 2]);
// type is inferred as [number, number]
/* ~~ Example 2 ~~ */
type Disposable = {
dispose(): void;
beforeDispose?(): void;
};
const s = satisfies<Disposable>()({
init() {
// Allocate
},
dispose() {
// Cleanup
},
});
// Is 'init' an excess property here?
// No error here. And the type is inferred as: { init(): void, dispose(): void }
/* ~~ Example 3 ~~ */
let y = satisfies<3>()(3);
let z: 3 = y;
// There is no error, type of y is inferred as 3 Also the other examples: const value1 = satisfies<number>()(3 as const); // `value1` is of type `3`
const value2 = satisfies<number[]>()([1, 2, 3] as [1, 2, 3]); // `value2` is of type `[1, 2, 3]`
// and another one
const value3 = satisfies<ReadonlyArray<number>>()([1, 2, 3] as const); // `value3` is of type `readonly [1, 2, 3]` |
@kasperpeulen thanks for the code and examples! Glad to see it works out, I wasn't on a computer and couldn't figure out what the satisfies function would look like. |
I'd like to interject a slight tangent (pessimistically, scope creep?) that I haven't seen pointed out yet, but I think would be a good use case for any keyword introduced for these static checks. Specifically, some generic constraints are impossible to express due to circular references, and unlike top-level variable/expression type constraints, there's no other place to even put a dead assignment to get the check. This has come up for me a number of times as a framework author wanting to type my APIs as tightly as possible to prevent accidental misuse. Most recently, I was trying to write an "isEnum" type guard (see also #30611): function isEnum<T extends Enum<T>>(enumContainer: T): (arg: unknown) => arg is T[keyof T] { ... }
type Enum<T, E = T[keyof T]> =
[E] extends [string] ? (string extends E ? never : unknown) :
[E] extends [number] ? (
true extends ({[key: number]: true} & {[P in E]: false})[number] ?
unknown : never) :
never; Unfortunately, because of the The reason this is a good fit is that there's no other good place to do this check, due to how TypeScript does its type checking. In C++, you can write a FWIW, my current workaround is pretty ugly and doesn't work universally. It essentially boils down to type NotAnEnum = {__expected_an_enum_type_but_got__: T};
type EnumGuard<T> = ... ? (arg: unknown) => arg is T[keyof T] : NotAnEnum<T>;
function isEnum<T>(enumContainer: T): EnumGuard<T> { ... } This gets the job done, but as folks upthread have pointed out, it moves the error from the construction to the usage - because the EDIT: added syntax highlighting |
One other reason in favour of allowing When working with a lot of tuples, I often have the following problem: declare function getTuple(): [number, number];
declare function calculate(tuple: [number, number]): number;
declare function memo<T>(factory: () => T): T;
function foo() {
const [a, b] = getTuple();
const tuple = memo(() => [b, a * 2]);
// TS2345: Argument of type 'number[]' is not assignable to parameter of type '[number, number]'.
// Target requires 2 element(s) but source may have fewer.
return calculate(tuple);
} I think most people would first function foo() {
const [a, b] = getTuple();
const tuple = memo(() => [b, a * 2] as const);
// TS2345: Argument of type 'readonly [number, number]' is not assignable to parameter of type '[number, number]'.
// The type 'readonly [number, number]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'.
return calculate(tuple);
} So now, what do you do? You have two options:
declare function getTuple(): [number, number | undefined];
declare function memo<T>(factory: () => T): T;
declare function calculate(tuple: [number, number]): number;
function foo() {
const [a, b] = getTuple();
// No error, but this code is not sounds anymore, silently converting number | undefined to a number
const tuple = memo(() => [b, a * 2] as [number, number]);
return calculate(tuple);
} With declare function getTuple(): [number, number | undefined];
declare function memo<T>(factory: () => T): T;
declare function calculate(tuple: [number, number]): number;
function foo() {
const [a, b] = getTuple();
// This will not compile. The type need to be changed to [number | undefined, number] or the null value must be handled.
const tuple = memo(() => [b, a * 2] satisfies [number, number]);
return calculate(tuple);
} |
Here's another use case to consider: import type { RequestHandler } from 'express'
interface RouteHandlers {
login: RequestHandler
logout: RequestHandler
}
export const routeHandlers: RouteHandlers = {
login(req, res) {
...
},
logout(req, res, next) {
...
}
}
export const routeHandlers = {
login: ((req, res) => satisfies<RequestHandler>({
...
})),
logout: ((req, res) => satisfies<RequestHandler>({
...
})),
} ...although in this case I can envision some syntax that would be even more concise, given that every value in the object should be a function of type |
To summarise what I recorded in the other issue (before I was helpfully referred to this feature discussion) and responding to @RyanCavanaugh ....
What I expected from a So for cases where the type IS satisfied it's a passthrough, and nobody has to reason about it at all, while if it ISN'T satisfied, then it would raise errors just like an assignment to a type (including excess property checks for literals) giving local and immediate feedback that it wasn't |
I'd like to pick up the conversation at #47920 to get a clean comment slate. There's a large write-up there. |
I would suggest something simple like Case 1 ("strict cast"): {prop: 123} as! MyInterface // fails on missing properties or properties not satisfying MyInterface Case 2 ("safe cast") {prop: 123} as? MyInterface // returns null if cast not successful Case 3 ("unsafe cast") {prop: 123} as MyInterface // just labels the object as the specified interface (currently the only possibility) |
Sometimes it's necessary (e.g. for guiding type inference, for ensuring sub-expression conforms to an interface, or for clarity) to change the static type of an expression. Currently TypeScript has the
as
(aka<>
) operator for that, but it's dangerous, as it also allows down-casting. It would be nice if there was another operator for implicit conversions only (type compatibility). I think this operator should be recommended in most cases instead ofas
.This operator can be implemented as a generic function, but as it shouldn't have any run-time effect, it would be better if it was incorporated into the language.
EDIT: Due to parameter bivariance, this function is not equivalent to the proposed operator, because asType allows downcasts too.
The text was updated successfully, but these errors were encountered: