-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Suggestion: one-sided or fine-grained type guards #15048
Comments
Just ran into a case where I want exactly this. Strongly agree with this idea. Not sure about |
Just bikeshedding here but I would prefer a syntax that was either more intuitive or more explicit. The What about function isInteger(value: any): (value is number) | false { /* ... */ } |
@aluanhaddad Actually, I think something along those lines would be more powerful – not just more intuitive – since it would allow for independent control over both the true and false sides of the conditional. I would suggest using function isInteger(value: any): value is number | false;
function isInteger(value: any): (value is number) | false; I've updated my suggestion in light of your comments. |
@mcmath I like the idea of using I was proposing function isInteger(value: any): value is number else false simply as the syntactic form for writing a one-sided type guard. That said, I like where you went with it. It does indeed open up a lot of power. |
I almost suggested |
It'd be good to collect some more use cases here. The main objection from the design meeting was that once there are two different kinds of type guards, there's an additional cognitive load for people to choose one or the other correctly. |
Three kinds of use case@RyanCavanaugh: I agree the extra complexity would be unwarranted if there were too few practical use cases. There are a lot of cases where predicate functions could be more That said, there are three general kinds of case where this kind of type guard
I'm going to assume the New ES2015+ predicate functionsThe predicate functions added in ES2015 as static methods of the function isNaN(value: number): boolean;
function isFinite(value: number): boolean;
function isInteger(value: number): boolean;
function isSafeInteger(value: number): boolean; With this proposal, these could be described more accurately as follows: function isNaN(value: any): value extends number else false;
function isFinite(value: any): value extends number else false;
function isInteger(value: any): value extends number else false;
function isSafeInteger(value: any): value extends number else false; Changes to existing predicate functions in ES2015+Along similar lines, ES2015 modifies the behavior of several static methods of function isExtensible(value: any): boolean;
function isFrozen(value: any): boolean;
function isSealed(value: any): boolean; These methods throw a TypeError when passed a non-object in ES5. Even in ES5, function isExtensible(value: object): boolean; But in ES2015+, they return false when passed a primitive function isExtensible(value: any): value as object else false; This kind of case is a bit more challenging than the first, as the TypeScript's User-defined predicate functionsIn keeping with the ES2015+ way of defining predicate functions, a TypeScript /**
* Tests whether a value is a non-negative integer.
* Non-numbers return false.
*/
function isWholeNumber(value: any): value is number else false; /**
* Tests whether a value is a string of length 1.
* Non-strings return false.
*/
function isCharacter(value: any): value is string else false; /**
* Tests whether a value is an empty array.
* Non-arrays return false.
*/
function isEmpty(value: any): value is any[] else false; |
I'm writing code where I frequently need to check whether a variable is a function or an object, but not I think that the It really bugs me when I design a module with really good, strict types and then I have to relax them because “not enough people need types this strict“, so the feature won't be implemented 🙁 |
Another related use case: an "isEmpty" function, like lodash's Here's a current annoying behavior:
A solution to this would require being able to specify the type guard in terms of a false result, rather than a true result. That would basically be the exact inverse of current custom type guards, but would not solve the OP's issue. A solution that takes care of both situations would be best. NOTE: It is currently easy to implement the inverse of
|
This SO seems like a valid use case |
I think this SO question also wants this feature. |
For what it’s worth, you can always work around this by making your type-guard even more fine-grained: if you use The shortcoming is when the other values you check aren’t things you can indicate in the type domain. Even there, though, you can use “type brands,” “type tags,” or whatever you want to call them to get a nominal type to indicate this—the brand means nothing in the positive case, but in the negative case it indicates, again, that the argument is not necessarily not the class in question, but rather not the intersection of that and the brand. One-sided type-guards might still be convenient—it’s not always trivial to indicate the real type, and producing a brand type just for this is annoying. But they don’t actually make things more type-safe. I have eliminated all of the cases in our code that were looking for one-sided type-guards using these approaches. |
@krryan That's awesome!! |
I ran into this issue with Array.isArray... I'm sure there's another issue somewhere related to this specifically, but the current A syntax like declare interface ArrayConstructor {
isArray(arr: any): arr is readonly unknown[] else not any[];
} while a bit awkward, would solve the problem below, wouldn't it? With Having the guard return tl;dr;I actually assigned Array.isArray to another export and redefined its typings with some overloads //Narrows unions to those that are of array types (not 100% sure this is correct, but it's the intent).
type _ArrayCompatibleTypes<T> = T extends readonly any[] ? T : never;
// If<Pred, Then, Else> and IsNever<T> are some utility types that do what they sound like.
type ArrayCompatibleTypes<T> = If<IsNever<_ArrayCompatibleTypes<T>>, T & readonly unknown[], _ArrayCompatibleTypes<T>>;
function isArray<T extends ArrayCompatibleTypes<any>>(obj: T): obj is ArrayCompatibleTypes<T>;
function isArray<TItem>(obj: Iterable<TItem> | null | undefined): obj is TItem[];
function isArray(obj: any): obj is unknown[]; |
I've found several scenarios in coding where I have roughly this pattern: class BaseClass {
type: SomeEnum;
}
class ChildClass extends BaseClass {
isCurrentlyActionable: boolean;
takeAction() {
doSomething();
}
}
function isChildClass(item: BaseClass): item is ChildClass {
return item.type === SomeEnum.ChildType;
}
function canTakeAction(item: BaseClass): boolean {
if (!isChildClass(item)) {
return false;
}
return item.isCurrentlyActionable;
} Now, there are a number of places where I need to call // Putting the redundant isChildClass() check only to satisfy TypeScript
if (!canTakeAction(item) || !isChildClass(item)) {
return;
}
// Now start using item like it's a ChildClass, such as:
item.takeAction(); One alternative to the redundant check is I can just cast the item after the Another alternative is to naively set the return type of const childClass: ChildClass = new ChildClass(...);
if (canTakeAction(childClass)) {
...
} else {
// childClass is now of type never :(
} So, for now, we just litter the code with the redundant checks. I actually haven't found myself needing the negative part of the type guard scenario as far as I can remember. I just need the positive side. |
We'd also benefit from either weak type guards or one-sided guards. Our type guard library has this exact issue where if you extend the builtin types with validators, you either lose type information or it becomes unsafe. This is safe: const Message = struct({ from: string, to: string, date: is(Date), content: string });
declare const x: any;
if (Message(x)) {
// x is { from: string, to: string, date: Date, content: string }
} else {
// x is any
} This is unsafe: const Positive = refinement(number, x => x > 0);
declare const x: number | string;
if (Positive(x)) {
// if we preserved guards, x is number
// otherwise we lose validated type information
} else {
// if we preserved guards, x would be string, which is very wrong
} With one-sided guards, we'd be able to un-guard the else branch so |
These one-sided typeguards are easier to infer than the two-sided one. As a result users would often not have to choose and could instead rely on ts. |
In Typescript, function tg(x: any) x is T { } means that if function tg(x: any) x is T1,T2 { } should mean that if |
Summary: We will be adding a qualifier to type guard definition to express the notion of a "one-sided" refinement (see [relevant TS issue](microsoft/TypeScript#15048)). The new syntax will be: ``` function foo(x: T): implies x is R { ... } ``` A one-sided type guard will only refine the then-branch of a conditional. We are adding this feature to unblock stricter [consistency checking](https://flow.org/en/docs/types/type-guards/#predicate-type-is-consistent-with-refined-type) for the else case of default type guards (two-sided). One-sided type guards continue to have the existing consistency checking. See this exchange for more context: https://twitter.com/danvdk/status/1765745099554668830. This diff adds AST support. Changelog: [internal] Reviewed By: SamChou19815 Differential Revision: D56506513 fbshipit-source-id: fb2adb11ca184bbf983973fa4d7369c5aa5f7cdb
This comment was marked as off-topic.
This comment was marked as off-topic.
@nathan-chappell Being able to narrow |
@krryan Sorry, I must have posted this in the wrong place. I was trying to return a value other than boolean from a typeguard, and one of the related issues must have pointed here (and I ended up here accidentally). |
I would love to see this, and the use case I've had is something like this: type Thing = A | B | C
type A = { kind: 'A', id: string, /* ... */ }
type B = { kind: 'B', id: string, /* ... */ }
type C = { kind: 'C', id: string, /* ... */ }
declare const things: Thing[];
// would love for this to infer `A | undefined`, rather than `Thing | undefined`
const theRightThing = things.find(it => it.kind === 'A' && it.id == desiredId);
// Similarly, if we could make it handle type variables, even better...
async function findBy<T extends Thing['kind']>(criteria: { kind: T; id: string }) {
const things = await readThingsFromSomewhere();
return things.find(it => it.kind === criteria.kind && it.id == criteria.id);
} |
@ethanresnick you can do that as of Typescript 5.5: -const theRightThing = things.find(it => it.kind === 'A' && it.id == desiredId);
+const theRightThing = things.filter(it => it.kind === 'A').find(it => it.id == desiredId); The type-predicate is correctly inferred in this case because it satisfies the following rules (quote from the release notes):
In short, we might need to get used to splitting up logical expressions into chained |
@ryami333 Thanks, but the whole point of this issue is to not need to split it like that (and doing that split obviously has runtime performance costs, in addition to arguably-worse readability). |
@ryami333 The function I passed to |
This is exactly the point of the issue as demonstrated by the usecase by @ethanresnick |
If the "else" type guard were implemented, it would be more useful for the following use case when noUncheckedIndexedAccess is enabled! const isEmptyArray = <A>(a: A[]): (a is []) else (a is [A, ...A[]]) => a.length === 0;
const fn = (arr: unknown[]) => {
if (isEmptyArray(arr)) {
// handle error
return; // early return
}
const firstElement = arr[0];
// Do something with `firstElement`
} Currently, we need to implement this as follows: const isNonEmptyArray = <A>(a: A[]): a is [A, ...A[]] => a.length > 0;
const fn = (arr: unknown[]) => {
if (!isNonEmptyArray(arr)) { // <- Hard-to-read double negative syntax :(
// handle error
return; // early return
}
const firstElement = arr[0];
// Do something with `firstElement`
} |
@noshiro-pf There might be a value gained by combining this suggestion with that compiler option… but be careful when correlating an array's function isNonEmptyArray<A>(a: A[]): a is [A, ...A[]] {
return a.length > 0;
}
const array: string[] = [];
array.length = 1;
if (isNonEmptyArray(array)) {
const firstElement = array[0];
// ^? const firstElement: string
console.log(firstElement.toUpperCase()); // ⚠️ Compiler diagnostic suppressed, but throws at runtime! (TypeError: Cannot read properties of undefined (reading "toUpperCase"))
}
// The guard logic is actually unsound:
if (array.length > 0) {
const firstElement = array[0];
// ^? const firstElement: string | undefined
console.log(firstElement.toUpperCase()); /* ✅ Caught
~~~~~~~~~~~~
'firstElement' is possibly 'undefined'.(18048) */
} Some refs for sparse arrays: |
To extend on that a little, for me the more concerning unsoundness is actually this: function isNonEmptyArray<A>(a: A[]): a is [A, ...A[]] {
return a.length > 0;
}
const array: string[] = ['1'];
if (isNonEmptyArray(array)) {
const firstElement = array.pop();
// ^? const firstElement: string
const secondElement = array[0];
// ^? const secondElement: string
console.log(secondElement.toUpperCase()); // ⚠️ Compiler diagnostic suppressed, but throws at runtime! (TypeError: Cannot read properties of undefined (reading "toUpperCase"))
} By locking in the type to "an array with at least one element", any function that modifies the array in place becomes very unsafe! This is getting off topic, but IMO to really make |
Thank you for your comments. However, I think these points are off-topic. If I had to say one thing, maybe my use of a mutable array as an example was a bad choice (this was simply to avoid cluttering the example with too many |
I see the primary value of this feature being how much easier it makes extracting conditional logic into helper functions, as currently you have to sacrifice type information in order to do that extraction. Playground Example I also agree with this comment by @taj-codaio:
I want a "weak" type guard that makes a type assertion when Really I just want the ability to write a function that can do what an
|
Untrue: you can use a type brand to note this, see Playground Example. It’s mildly inconvenient, since you need the type brand, but ultimately it’s very effective. |
@krryan Good point😄 I noticed that when I tried to do an additional assertion (branding an already branded type), it seems to revert back to the usual type guard behavior. It seems you can make this work if you create a new brand for each type guard, but it seems this would get unwieldy |
@ConnorUllmann Ah, that is just because I messed up my definition of |
The Problem
User-defined type guards assume that all values that pass a test are assignable to a given type, and that no values that fail the test are assignable to that type. This works well for functions that strictly check the type of a value.
But some functions, like
Number.isInteger()
in ES2015+, are more restrictive in that only some values of a given type pass the test. So the following does't work.The current solution – the one followed by the built-in declaration libraries – is to forgo the type guard altogether and restrict the type accepted as an argument, even though the function will accept any value (it will just return
false
if the input is not a number).A Solution: an "as" type guard
There is a need for a type guard that constrains the type when the test passes but not when the test fails. Call it a weak type guard, or a one-sided type guard since it only narrows one side of the conditional. I would suggest overloading the
as
keyword and using it likeis
.This is only a small issue with some not-too-cumbersome workarounds, but given that a number of functions in ES2015+ are of this kind, I think a solution along these lines is warranted.
A more powerful solution: an "else" type guard
In light of what @aluanhaddad has suggested, I feel the above solution is a bit limited in that it only deals with the true side of the conditional. In rare cases a programmer might want to narrow only the false side:
To account for this scenario, a fine-grained type guard could be introduced: a type guard that deals with both sides independently. I would suggest introducing an
else
guard.The following would be equivalent:
And the following would narrow either side of the conditional independently:
For clarity, parentheses could optionally be used around one or both sides:
At this point I'm not too certain about the syntax. But since it would allow a number of built-in functions in ES2015+ to be more accurately described, I would like to see something along these lines.
The text was updated successfully, but these errors were encountered: