question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Allow custom guards to return non-boolean values, with conditional type results

See original GitHub issue

Suggestion

Sometimes we want to write a guard function that doesn’t return a boolean. For example,

type Success<T> = { ok: true, value: T }
type Fail = { ok: false, error: string }
type Result<T> = Success<T> | Fail

function showError<T>(result: Result<T>): string | null {
  if (result.ok) {
    return null;
  }
  return `Oops, something went wrong: ${result.error}`;
}

here, we render a Result<T> value into either a string error message, or return null if there’s no error. This kind of pattern can be used in React, for example, where we could write

function Component() {
  const result: Result<number> = getResult();
  return <>{showError(result) ?? process(result.value)}</>;
}

However, 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:

function isError<T>(result: Result<T>): result is Fail {
  return !result.ok;
}

and using this function, we could write

function Component() {
  const result: Result<number> = getResult();
  return <>{isError(result) ? showError(result) : process(result.value)}</>;
}

because tsc now uses the isError custom type guard to narrow the type of result.

However, this means that we can’t use showError to narrow our value. Especially if the arguments to isError/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 we showError a value, it’s the same as the value passed to isError).

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:

function showError<T>(result: Result<T>): (result is Fail) ? string : null {
  if (result.ok) {
    return null;
  }
  return `Oops, something went wrong: ${result.error}`;
}

Here, the return type is now annotated as (result is Fail) ? string : null. This means that if result is Fail, then the function returns a string, otherwise, it returns a null.

So if we verify that showError(result) is null, then that means that result must not have been a Fail value, so we can narrow it to a Success<T>. This means that showError(result) ?? process(result.value) will now pass, since on the right hand side of ?? we are able to narrow showError(result) to null, which means that we can narrow result to Success<number> which has a .value field.

🔍 Search Terms

  • custom type guard
  • conditional type guard

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

  • This wouldn’t be a breaking change in existing TypeScript/JavaScript code
    • This new syntax doesn’t overlap with existing syntax, so this will have no affect on existing code. Avoiding ambiguity with regular conditional types, or custom type guards that narrow to conditional types is important, but the proposed syntax avoids any conflicts
  • This wouldn’t change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn’t a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript’s Design Goals.

⭐ 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:

function myCustomTypeGuard(x: ArgType): (x is SmallerArgType) ? Positive : Negative {
  ...
}

If the result of the function call myCustomTypeGuard(x) is ever narrowed so that the result doesn’t overlap at all with Negative, then x is automatically narrowed to SmallerArgType.

If the result of the function call myCustomTypeGuard(x) is ever narrowed so that the result doesn’t overlap at all with Positive, then x is automatically narrowed to exclude SmallerArgType.

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

import * as React from "react";

type Result<T> = { ok: true; value: T } | { ok: false; error: string };

// New syntax is here: conditional custom type guard annotation
function showError<T>(result: Result<T>): (result is Fail) ? string : null {
  if (result.ok) {
    return null;
  }
  return `Oops, something went wrong: ${result.error}`;
}

function getResult(): Result<number> {
  return null as any;
}
function process(x: number): string {
  return `the value is ${x}`;
}

function Component() {
  const result: Result<number> = getResult();
  return <>{showError(result) ?? process(result.value)}</>;
}

💻 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.

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:9
  • Comments:5 (3 by maintainers)

github_iconTop GitHub Comments

3reactions
Nathan-Fennercommented, Nov 4, 2021

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:

playground

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.

2reactions
Nathan-Fennercommented, Nov 3, 2021

The goal here is to produce a new value and simultaneously enable narrowing based on that value, so asserts doesn’t work for a similar reason, since functions with asserts have to be void, just like custom type guards must return a boolean.

Read more comments on GitHub >

github_iconTop Results From Across the Web

How To Do Anything in TypeScript With Type Guards
Type guards are conditional checks that allow types to be ... When using a boolean type guard, the value is implicitly casted to...
Read more >
Custom TypeScript type guard for "not undefined" in separate ...
You could use a template function like this: function isDefined<T>(val: T | undefined | null): val is T { return val !== undefined...
Read more >
PEP 647 – User-Defined Type Guards
Return statements within a type guard function should return bool values, and type checkers should verify that all return paths return a bool....
Read more >
How to use type guards in TypeScript - LogRocket Blog
Type guards are regular functions that return a boolean, taking a type ... about the type of a variable, usually within a conditional...
Read more >
Router Guards • Angular - Courses
Guard Types · Maybe the user must login (authenticate) first. · Perhaps the user has logged in but is not authorized to navigate...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found