Open mcmath opened 7 years ago
Just ran into a case where I want exactly this. Strongly agree with this idea. Not sure about as
vs. is
being the syntactical distinguishing feature, but definitely want a one-sided type-guard in some fashion.
function isInteger(value: any): value as number { / ... / }
Just bikeshedding here but I would prefer a syntax that was either more intuitive or more explicit. The as
might be familiar to C# programmers as a conditional reference conversion but the analogy is a stretch.
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 else
instead of |
to separate each side, as the following could be confused, especially in more complicated cases with a lot of parentheses:
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 else
to reduce parentheses, but I was actually not proposing branching.
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 |
myself, but I like else
a lot more. But ultimately I held off on the |
/else
suggestion because it's a kind of weird case where value is number
implies value is number else not number
which relies on type negation/subtraction (another feature I very, very much want). Basically, it's kind of confusing that the two-sided type-guard is the default.
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.
@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 accurately described with this proposal; but such accuracy may not be necessary in many cases.
That said, there are three general kinds of case where this kind of type guard could be put to use:
I'm going to assume the else
syntax in the examples below, but I'm not
suggesting that should be the final syntax.
The predicate functions added in ES2015 as static methods of the Number
constructor accept any value, and return false when passed non-number values.
TypeScript currently describes them as accepting only numbers:
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;
Along similar lines, ES2015 modifies the behavior of several static methods of
the Object
constructor initially introduces in ES5. TypeScript
currently describes them as follows:
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, they should be described like so:
function isExtensible(value: object): boolean;
But in ES2015+, they return false when passed a primitive value. So with this proposal, they would be described as follows, but only when targeting ES2015 and above:
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 ES2015 declarations currently reference the ES5 declarations.
In keeping with the ES2015+ way of defining predicate functions, a TypeScript user might want to define any number of similar functions.
/**
* 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 null
. I need that to decide whether I should use Map
or WeakMap
.
I wanted to move the type check to a type guard function, so that I don't have to write (typeof x === "object" || typeof x === "function") && x !== null
over and over. However as the current type guards are only an implication of a type, not equivalence, I can't do it without making the types less strict.
I think that the x is T else false
seems good to me, as the addition makes it clear it's stricter. People who don't need the fine-grained type guard don't even need to know it exists. Adding cognitive to programmers doesn't seem like a good reason not to implement it – either you use the "normal" type guard, or something doesn't work, you google it and change that to the "strict" type guard. Not much to think about.
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 _.isEmpty
would be more useful if a false
result could indicate to the compiler that the param is not null | undefined
.
Here's a current annoying behavior:
declare const someArray: number[] | undefined;
if (!_.isEmpty(someArray)) {
// compiler error: someArray may be undefined.
// requires a non-null assertion event though I know it's a non-empty array
console.log(someArray[0]);
}
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 isEmpty
as a type guard as follows:
export function isNonEmpty<T>(value: T | undefined | null): value is T {
return !_.isEmpty(value);
}
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 value is TheClass & { someOtherValueYouChecked: 'foobar' }
, then false results will just mean it’s either not a member of TheClass
or else it is but someOtherValueYouChecked
wasn’t foobar
—which is exactly correct.
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 arr is any[]
return type would benefit from a definition with one of these syntaxes.
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 arr is any[]
:
Currently the true branch loses all type safety for elements of an array even if the element types were previously known (and makes a readonly array writable, doesn't it?)
Having the guard return arr is unknown[]
breaks narrowing in the false branch for a union of SomeArray | SomeObject
.
I actually assigned Array.isArray to another export and redefined its typings with some overloads
to get better types out of the true branch (the more common use case), and I only use Array.isArray()
when I want to assert the false branch - not an array of any type. But that still requires some care on my part that a more expressive type guard could help avoid.
//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 canTakeAction
on some BaseClass
item where I do not yet know the type. I find myself littering the code with this awkwardness, comment and all:
// 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 if
block. Not really much better than the typecheck above.
Another alternative is to naively set the return type of canTakeAction
to be item is ChildClass
. That works well for this exact scenario, but I'll be in bad shape when I get to the scenario:
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 runtype
would be safe to use with custom validators.
I am finding myself in a situation that I think is related to this issue. I would like to be able to specify the type when the condition in the guard function is not met:
type Item = {
className: 'Item';
id: string;
}
type Collection = {
className: 'Collection';
id: string;
}
type CollectionFragment = {
root: Item | Collection;
name: string;
children: string[];
// Some other properties..
}
// We would like this to be a guard, such that when the condition is met:
// item is Item | Collection
// When it is unmet:
// item is just Item
function isRoot<T extends Item | Collection>(fragment: CollectionFragment, item: T): boolean {
return fragment.root.id === item.id;
}
const item: Item | Collection;
if (isRoot(item)) {
// item should be Item | Collection
} else {
// item is definitly _not_ Collection.
// item should be Item
}
Is there a workaround for making this possible? I can do this with a isNotRoot
function instead, but this isn't ideal. 🤔
From #36275, another use case of this is for Array.includes
:
const arr: number[] = [1,2,3,4]
function sample(x: unknown): void {
if(arr.includes(x)) {
// x is definitely a number
} else {
// x may or may not be a number
}
}
For what it's worth my use case is
function isActive(product: Product | null): product is Product {
product && product.status === 'active';
}
Then when I chain isActive
with other checks I don't have to check for null again
if (isActive(product) && product && product.code === 'PRODUCT_CODE') {
...
}
becomes
if (isActive(product) && product.code === 'PRODUCT_CODE') {
...
}
but then if I check for !isActive
my product is narrowed to null
but in reality it could be a Product
with a different status
property
// error here because product is narrowed to null
if (!isActive(product) && product && product.code === 'PRODUCT_CODE') {
...
}
I wish I could write something like
function isActive(product: Product | null): product is Product or Product | null {
product && product.status === 'active';
}
@Babeetlebum A solution here is to type isActive
as (product: Product | null) => product is Product & { status: 'active'; }
. If you do that, Typescript will know in a false
case that product
may well still be a Product
, just one that doesn’t happen to have status: 'active';
(it will also be aware of the null
possibility). This effectively creates a one-sided typeguard: in a !isActive(product)
branch, product
will still have type Product | null
(because there are no negative types that would encode the fact that status
is known to not be 'active'
).
In your example,
function isActive(product: Product | null): product is Product & { status: 'active'; } {
product && product.status === 'active';
}
if (isActive(product) && product.code === 'PRODUCT_CODE') {
// product is Product
// product.status is 'active'
// product.code is 'PRODUCT_CODE'
}
if (!isActive(product) && product && product.code === 'PRODUCT_CODE') {
// product is Product
// product.status is Product['status'] (probably string, but whatever you typed it as in Product)
// product.code is 'PRODUCT_CODE'
}
We also have a common case in our code-base where this would help: we have a pair of isEmpty
/isBlank
functions to validate user input. They accept null-ish parameters, but if they return false
we know that the result is a string.
It’s usually more convenient when validating to deal with all the error cases first (e.g., if(isBlank(input)) throw Error("FOO is required")
), and then go on dealing with the “happy case”. Doing it the other way around feels more cumbersome, because you need an extra indent for the “meat” of the code.
I can see needing something like isEmpty
in the same role, though for some reason I can’t remember needing it. We have a “has-at-least-one-element” predicate for arrays; I’m not sure why I never felt the need to use its inverse for type narrowing, but it could be just “I knew it couldn’t be done”.
I don’t have a strong opinion on the syntax, but while I was considering adding a feature request (before finding this issue) I did think of proposing something like true else X is T
, so take that as weak evidence that it’s intuitive.
The other idea I had was for something like function test(x): x is not T
, but that seems a bit less powerful. I could imagine something like function test(x: A|B|C): x is A else x is B
, which maybe throws if x is C, though I’m not sure I can find a good use-case for it. Now that I think of it, I could also imagine something like test(x): "a" if x is A else "b" if x is B else "c" if x is C else false
, which could be useful in some cases. (You would use it with a switch.) I don’t have a concrete case where I wished for it before today, but that might be because I knew it wasn’t possible and thought of different conclusions. I can think of places where I might use it (as a replacement of the visitor pattern, perhaps).
I wonder if this can be a general workaround that lets you create unlimited type guards without having to create a specific nominal type for each of them. The idea is that, if your input has type U
and I'm guarding for type T
, then I return the intersection of types T
and a SubtypeOf<U>
, which is a (proper) subtype of U
. Since TS doesn't know how to produce type U - SubtypeOf<U>
, it'll just type it as U
when the guard returns false.
It seems to me that my SubtypeOf<T>
type works fine, but the resulting types don't look great when hovering over the variables. I haven't been able to come up with a better looking alternative.
declare const container: unique symbol
type SubtypeOf<T> = T & { [container]: T }
const integer = <U>(n: U): n is number & SubtypeOf<U> => typeof n === "number" && Number.isInteger(n);
const multipleOf = <U>(m: number, n: U): n is number & SubtypeOf<U> => typeof n === "number" && n % m === 0;
function test(n: unknown) {
if (typeof n === "string" || typeof n === "number") {
const a = n; // string | number
if (integer(n)) {
const b = n; // number & { [container]: string | number }
if (multipleOf(10, n)) {
const c = n; // number & { [container]: string | number } & { [container]: number & { [container]: string | number } }
}
else {
const d = n; // number & { [container]: string | number }
}
}
else {
const e = n; // string | number
}
}
}
@jsoldi I don’t think that relying on a shortcoming of TS’s compiler is a great answer here. Someday we might get advancements that make type subtractions like that possible. Better to encode the actual type—which we can’t do for multipleOf
—or use type brands/tags/whatever. Could even do
declare function multipleOf<U, M extends number>(m: M, n: U): n is MultipleOf<M>;
declare abstract class MultipleOf<M extends number> {
private static readonly $Factor$: unique symbol;
private readonly [MultipleOf.$Factor$]: Record<M, true>;
}
allowing you to require that arguments passed to certain functions be checked with multipleOf
first.
@krryan Yes, that'd be a better solution for a limited set of guards. But I'm trying to come up with a general solution, in particular one that works with my to-typed package. The package lets you refine existing guards on the fly (somehow like the filter
array method). Having to create types for every refinement would defeat the purpose of being able to do it on the fly, so the perfect solution here would be actual one-sided type guards.
I really doubt TS will ever have a type like number - { [container]: number | string }
. But if it does, it'll likely require some special keyword, instead of being inferred automatically. But even if it is, I really hope actual one-sided type guards are implemented before. Worst case scenario, my type-guards will go back to behaving as normal type-guards.
@jsoldi In that case you could just use declare abstract class OneSided { private static readonly $: unique symbol; private readonly [OneSided.$]: true; }
and value is Whatever & OneSided
.
@krryan I tried that first, but it'll break on the second level. See the c
const here:
declare abstract class OneSided { private static readonly $: unique symbol; private readonly [OneSided.$]: true; }
const integer = (n: unknown): n is number & OneSided => typeof n === "number" && Number.isInteger(n);
const multipleOf = (m: number, n: unknown): n is number & OneSided => typeof n === "number" && n % m === 0;
function test(n: unknown) {
if (typeof n === "string" || integer(n)) {
const a = n; // string | (number & OneSided)
if (multipleOf(2, n)) {
const b = n; // number & OneSided
}
else {
const c = n; // string, but could really be an odd integer
}
}
else {
const d = n; // unknown
}
}
This happens because OneSided
produces a subtype of any given type that's not already intersected with it; (number & OneSided) & OneSided
= number & OneSided
. So the missing feature is a way to produce subtypes for any possible type, which is what I've attempted to do with SubtypeOf
. In any case, both options rely on TS being unable to infer a subtraction type.
Number.isFinite
is a fairly common way to check for validity of values before doing mathematical operations. In high-performance use cases (graphics, in my case), it's annoying to have to add extra, unnecessary runtime logic just to please the compiler. Would definitely appreciate the ability to use Number.isFinite
and similar functions as a one-sided type guards.
@tsherif TypeScript supports type assertions for this. There's no runtime code needed.
data.filter(Number.isFinite as (val: unknown) => val is number)
// or
type NumberTypeGuard = (val: unknown) => val is NumberTypeGuard
data.filter(Number.isFinite as NumberTypeGuard)
To me, since this is a mirror of the existing conditional types syntax, it might be useful to use it here too. Following is my syntax proposal, starting from the original issue proposal's "else" type guard, and taking on some tangents:
The following would be equivalent:
function isCool(value: any): boolean { /* ... */ }
function isCool(value: any): true ? value is any : value is any { /* ... */ }
And the following would narrow either side of the conditional independently:
let x: number | string = getNumberOrString();
// Narrows only the true side of the conditional
function isInteger(value: any): true ? value is number : value is unknown { /* ... */ }
if (isInteger(x)) {
// x: number
} else {
// x: number | string
}
// Narrows only the false side of the conditional
function isNotInteger(value: any): true ? value is unknown : value is number { /* ... */ }
if (isNotInteger(x)) {
// x: number | string
} else {
// x: number
}
For typechecking, the type of the return value must be the widened type of all of the literal types in the condition. In this case, since we're using a boolean
literal, the else condition is Exclude<boolean, true>
, ie, false
.
This syntax has the added benefit of handling any literal primitive type so that type narrowing can be more expressive in switch statements or literal conditionals: Allow return values of true
/false
, 0
, 1
, other number
literals, 'strings'
, undefined
, null
, enum
s to play a role in type guards.
For example, wrapping typeof
in a function with this kind of type guard would look something like this:
function typeofAsAFunction(value: any): 'bigint'
? value is bigint
: 'boolean'
? value is boolean
: 'function'
? value is Function
: 'number'
? value is number
: 'object'
? value is object | null
: 'string'
? value is string
: 'symbol'
? value is symbol
: 'undefined'
? value is undefined
: value is never {
return typeof value;
}
Note that the value is never
syntax means that the return type of the function must be "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"
. If the last clause was value is unknown
, the return type of the function would be string
.
Alternatively, move the value is
outside of the conditional like the following
function typeofAsAFunction(value: any): value is (
'bigint'
? bigint
: 'boolean'
? boolean
: 'function'
? Function
: 'number'
? number
: 'object'
? object | null
: 'string'
? string
: 'symbol'
? symbol
: 'undefined'
? undefined
: never
) {
return typeof value;
}
A regular boolean conditional could be
// Narrows only the true side of the conditional
function isInteger(value: any): value is true ? number : unknown { /* ... */ }
I would expect most style guides to mandate parentheses in this case though, so it's a little clearer. Or, instead of leaving that up to style guides, make it a restriction in the language itself. (which could simplify parsing; this restriction already exists with types like A | (() => A)
)
// Narrows only the true side of the conditional
function isInteger(value: any): value is (true ? number : unknown) { /* ... */ }
If there's a need to express the behaviour of classic / symmetric type guards, I think the following syntax would be equivalent:
function isFoo(x: any): x is Foo { /* ... */ }
function isFoo(x: any): true ? x is Foo : x is Exclude<typeof x, Foo> { /* ... */ }
// or, this would just require an explicit template type, since `typeof` would work differently here compared to everywhere else
function isFoo<T>(x: T): true ? x is Foo : x is Exclude<T, Foo> { /* ... */ }
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.
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 tg(x)
is true
, then x
is of type T
and if tg(x)
is false
, then x
is not of type T
.
I think that if typeguards are to be generalised, the negation in the false branch should be preserved to stay consistent. I'm not proposing any syntax, but something like
function tg(x: any) x is T1,T2 { }
should mean that if tg(x)
is true
, then x
is of type T1
and if tg(x)
is false
, then x
is not of type T2
.
I just wanted to demonstrate a workaround that I'm using. Basic idea is either use a ref / out
parameter or pass in a callback, using a smart default value so the function can be used as a normal typeguard.
type D = { foo: "bar|baz" }
// The thing we want: check if x is D and return x.foo
//
// function isD(x: any): x is D {
// if (typeof x === 'object' && 'foo' in x && (x.foo === 'bar' || x.foo === 'bar')) return x.foo
// else return null;
// }
// Workaround: return boolean but store result (can also be used as normal typeguard without getting value)
//
function isD(x: any, out: { value: null | D['foo'] } = { value: null }): x is D {
if (typeof x === 'object' && 'foo' in x && (x.foo === 'bar' || x.foo === 'bar')) {
out.value = x.foo
return true;
}
else return false;
}
// Generalized Workaround: pass in a callback (of course)
//
function isD2(x: any, handleValue: (foo: D['foo']) => void = () => { }): x is D {
if (typeof x === 'object' && 'foo' in x && (x.foo === 'bar' || x.foo === 'bar')) {
handleValue(x.foo)
return true;
}
else return false;
}
@nathan-chappell Being able to narrow foo
while also returning foo.foo
is a separate concern unrelated to this issue. I believe there is a proposal for something along those lines, but this isn’t it.
@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):
- The function does not have an explicit return type or type predicate annotation.
- The function has a single return statement and no implicit returns.
- The function does not mutate its parameter.
- The function returns a boolean expression that’s tied to a refinement on the parameter.
In short, we might need to get used to splitting up logical expressions into chained .filter
+.find
calls, but it does work now.
@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 find()
in my example — it => it.kind === 'A' && it.id == desiredId
— would be a one-sided type guard in the sense that, if the function returns true, then the input argument is definitely of type A
but, if the function returns false, the input might still be an A
(i.e., isn't necessarily not an A
)
I'm sorry but that's not the point of this issue at all - your comment does not have anything to do with "one-sided" type-guard at all. Let's get back on topic.
This is exactly the point of the issue as demonstrated by the usecase by @ethanresnick
A more powerful solution: an "else" type guard
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`
}
If the "else" type guard were implemented, it would be more useful for the following use case when noUncheckedIndexedAccess is enabled!
@noshiro-pf There might be a value gained by combining this suggestion with that compiler option… but be careful when correlating an array's length
with its valid indexes: they aren't necessarily related and the compiler will tell you that — the type guard is suppressing the diagnostic contributed by that setting:
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 noUncheckedIndexedAccess
viable TS would need to gain some sort of array length inference (and something to make indexed property access more ergonomic).
@jsejcksn @ehoogeveen-medweb
Thank you for your comments. However, I think these points are off-topic.
As with type casting, type guard functions are inherently unsafe and should be used at your own risk, with full awareness of the pitfalls.
In this case, improving the runtime implementation of isEmptyArray
is off-topic, so I just implemented it simply.
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 readonly
modifiers).
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 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.
I want a "weak" type guard that makes a type assertion when true
and makes no assertion when false
, as opposed to the current type guard that makes an assertion in both cases.
Really I just want the ability to write a function that can do what an if
already does:
if (typeof n === 'number' && n > 5) {
// n is number
} else {
// n is string | number
}
@ConnorUllmann
currently you have to sacrifice type information in order to do that extraction
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 ☹️ A built-in feature for this would be fantastic to avoid these workarounds.
@ConnorUllmann Ah, that is just because I messed up my definition of As
: the non-static
member should be Record<T, true>
(which requires an extends keyof any
constraint on T
—there are other options if that limitation is a problem but I can’t think of any good reason to need other brands like that). Playground Example
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.