-
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
Allow custom guards to return non-boolean values, with conditional type results #46650
Comments
If you're willing to throw exceptions, you can use function showError(x: Fooey): asserts x is FooBar
{
if (x instanceof FooBar) {
fooBar(x);
}
else {
throw TypeError("Not a foobar object");
}
} |
The goal here is to produce a new value and simultaneously enable narrowing based on that value, so |
Closest thing to a duplicate is #31376 but the conditionality is an interesting distinction. #15048 also has a similar conditionality theme but is still about functions that return booleans at runtime. I’ll leave this open as a suggestion but I have to say this seems incredibly complex, both to reason about and to implement.
This is a problem in general without negated types. The compelling examples are discriminated unions, but I think it would get really confusing really fast if you tried to move away from discriminated unions. |
I'm suggesting the same (semi ad-hoc) behavior that custom type guards get, so this is the status quo: function isNum(x: string | number): x is number {
return typeof x === "number";
}
const y: "a" | "b" | "c" | 1 | 2 | 3 = null as any;
if (isNum(y)) {
y; // y's type is narrowed to 1 | 2 | 3
} else {
y; // y's type is narrowed to "a" | "b" | "c"
} The new part is just detecting an arbitrary narrowing of the return value of the guard instead of a truthiness narrowing of the return value; what this does to the argument's type is exactly the same as what tsc already does for existing type guards. |
FWIW, here's a way to represent the same intention with current typescript as of 4.8.2 (Playground) Note: // Library using code
import React from "react";
declare function getResult(): Result<number>;
declare function processValue(v: number): string;
function formatError(e: string): string {
return `Oops, something went wrong: ${e}`;
};
export default function Component() {
const result = getResult();
const msg =
FluentResult.wrap(result)
.map(processValue)
.catch(formatError)
.unwrap()
.value // For this to work you need to set return type of `catch` to `Success`
return <>{msg}</>;
}
// Library code
type Success<T> = { ok: true, value: T };
type Fail = { ok: false, error: string };
export type Result<T> = Success<T> | Fail;
const map = <T, U>(r: Result<T>, f: (t: T) => U): Result<U> =>
r.ok ? { ok: true, value: f(r.value) } : r;
const flatMap = <T, U>(r: Result<T>, f: (t: T) => Result<U>): Result<U> =>
r.ok ? f(r.value) : r;
const catch_ = <T, U>(r: Result<T>, f: (e: string) => U): Success<T | U> =>
r.ok ? r : { ok: true, value: f(r.error) };
const flatCatch = <T, U>(r: Result<T>, f: (e: string) => Result<U>): Result<T | U> =>
r.ok ? r : f(r.error);
const then = <T, U>(r: Result<T>, onSuccess: (t: T) => U, onFailure: (e: string) => U): Success<U> =>
catch_(map(r, onSuccess), onFailure);
const flatThen = <T, U>(r: Result<T>, onSuccess: (t: T) => Result<U>, onFailure: (e: string) => Result<U>): Result<U> =>
r.ok ? onSuccess(r.value) : onFailure(r.error);
// Optionally you can expose a fluent interface for better DX
// The second type parameter and `wrap` is solely to infer a success on `FluentResult.catch`
export class FluentResult<T, R extends Result<T> = Result<T>> {
private result: R;
constructor(result: R) {
this.result = result;
}
static wrap<T>(result: Result<T>): FluentResult<T, Result<T>> {
return new FluentResult(result);
}
unwrap(): R {
return this.result;
}
map<U>(f: (t: T) => U): FluentResult<U> {
return new FluentResult(map(this.result, f));
}
flatMap<U>(f: (t: T) => Result<U>): FluentResult<U> {
return new FluentResult(flatMap(this.result, f));
}
catch<U>(f: (e: string) => U): FluentResult<T | U, Success<T | U>> {
return new FluentResult(catch_(this.result, f));
}
flatCatch<U>(f: (e: string) => Result<U>): FluentResult<T | U> {
return new FluentResult(flatCatch(this.result, f));
}
then<U>(onSuccess: (t: T) => U, onFailure: (e: string) => U): FluentResult<U> {
return new FluentResult(then(this.result, onSuccess, onFailure));
}
flatThen<U>(onSuccess: (t: T) => Result<U>, onFailure: (e: string) => Result<U>): FluentResult<U> {
return new FluentResult(flatThen(this.result, onSuccess, onFailure));
}
} |
Suggestion
Sometimes we want to write a guard function that doesn't return a boolean. For example,
here, we render a
Result<T>
value into either astring
error message, or returnnull
if there's no error. This kind of pattern can be used in React, for example, where we could writeHowever, if we try this code today, we get an error:
Property 'value' does not exist on type 'Result<number>'.
This makes sense, since tsc has no way of knowing that we're narrowing the value using
showError
.Now, we can almost reach for a custom type guard. We can easily write:
and using this function, we could write
because tsc now uses the
isError
custom type guard to narrow the type ofresult
.However, this means that we can't use
showError
to narrow our value. Especially if the arguments toisError
/showError
are larger or more complicated, this means we have a lot of data that needs to be kept in-sync in source, or we'll render the wrong error (we need to ensure that whenever weshowError
a value, it's the same as the value passed toisError
).Since our
showError
already provides enough context that narrowing is possible, ideally we'd be able to use it as-is by giving the compiler a hint about what its return types mean.But we're limited by the fact that custom type guards must return
boolean
values.The suggestion is to allow custom type guard conditions, such as the following:
Here, the return type is now annotated as
(result is Fail) ? string : null
. This means that ifresult
isFail
, then the function returns astring
, otherwise, it returns anull
.So if we verify that
showError(result)
isnull
, then that means thatresult
must not have been aFail
value, so we can narrow it to aSuccess<T>
. This means thatshowError(result) ?? process(result.value)
will now pass, since on the right hand side of??
we are able to narrowshowError(result)
tonull
, which means that we can narrowresult
toSuccess<number>
which has a.value
field.🔍 Search Terms
We can sort of see this as a way to explicitly annotate (but not check) control-flow for functions as described in #33912 in much the way that custom type guards are used by tsc to narrow types, but the implementation is not really checked (other than that it returns a
boolean
).✅ Viability Checklist
⭐ Suggestion
Custom type guard functions should optionally support conditional annotations in their return types, allowing them to return non-
boolean
types. A function can be annotated as:If the result of the function call
myCustomTypeGuard(x)
is ever narrowed so that the result doesn't overlap at all withNegative
, thenx
is automatically narrowed toSmallerArgType
.If the result of the function call
myCustomTypeGuard(x)
is ever narrowed so that the result doesn't overlap at all withPositive
, thenx
is automatically narrowed to excludeSmallerArgType
.The function's implementation is just checked to ensure that it returns
Positive | Negative
; just as with existing custom type guards, tsc ignores the actual logic used to decide whether the positive or negative value should be returned.Essentially, the existing custom type guard function syntax
(arg: T): arg is Blah
is identical to the expanded form(arg: T): (arg is Blah) ? true : false
.📃 Motivating Example
💻 Use Cases
This makes it easier to write flexible type-guards, such as the example above, especially for dealing with errors or processing "exceptional" cases.
It's possible to separately use a regular custom type guard function and then use a different function to process the result, but this can be redundant or error-prone, since you have to ensure that the two calls remain in sync with each other.
The text was updated successfully, but these errors were encountered: