Skip to content
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

Asynchronous type guards and assertion signatures #37681

Open
5 tasks done
niko278 opened this issue Mar 30, 2020 · 10 comments
Open
5 tasks done

Asynchronous type guards and assertion signatures #37681

niko278 opened this issue Mar 30, 2020 · 10 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@niko278
Copy link

niko278 commented Mar 30, 2020

Search Terms

async, asynchronous, promise, type guards, assertion signatures, asserts

Suggestion

TypeScript currently supports user-defined type guards and assertion signatures.

function isCustomType(value: unknown): value is CustomType {
    // ...
}

function assertIsCustomType(value: unknown): asserts value is CustomType {
    // ...
}

I think it would be great if we could also define custom asynchronous type guards and assertions.

async function isCustomType(value: unknown): Promise<value is CustomType> {
    // ...
}

async function assertIsCustomType(value: unknown): Promise<asserts value is CustomType> {
    // ...
}

Use Cases

This feature would allow to check types and validate data asnychonously. Please look at the examples below.

Examples

Imagine the code like this:

interface User {
    name: string;
}

type Valid<T> = T & {
    readonly $valid: unique symbol;
}

async function createUser(user: User): Promise<void> {
    validateUser(user);
    await saveValidUserToDatabase(user);
}

function validateUser(user: User): asserts user is Valid<User> {
    if (user.name.length < 5) {
        throw new Error('User name must be at least 5 characters long');
    }
}

async function saveValidUserToDatabase(user: Valid<User>): Promise<void> {
    // ...
}

But sometimes, validation is done asynchronously, e.g. on server-side. Currently, you can achieve it this way:

async function createUser(user: User): Promise<void> {
    await validateUser(user);
    await saveValidUserToDatabase(user as Valid<User>);
}

async function validateUser(user: User): Promise<void> {
    // ...
}

But if TypeScript supported asynchronous assertions, you could skip as Valid<User> type assertion and let the TS do the job:

async function createUser(user: User): Promise<void> {
    await validateUser(user);
    await saveValidUserToDatabase(user);
}

async function validateUser(user: User): Promise<asserts user is Valid<User>> {
    // ...
}

Exactly the same issue could be presented for user-defined type guards.

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • 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, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@MartinJohns
Copy link
Contributor

Partially a duplicate of #37515. Unfortunately neither of you respected the issue template. :-(

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Mar 31, 2020
@Etheryte
Copy link

Until this gets implemented, a fairly straightforward workaround is to return the input with the corresponding expected type if the check passes. For example, when checking some input against a Yup schema:

export async function validateWithSchema<T, K>(data: T, schema: yup.Schema<K>) {
  // `schema.validate` will throw on invalid input
  await schema.validate(data);
  return (data as unknown) as K;
}

And then later on:

const typedData = await validateWithSchema(unknownInput, schema);

From there on, typedData will have the types inferred from the Yup schema definition.

@zraineri
Copy link

Is there any plan for this to be implemented?

I think it would be incredibly useful especially as async functions are so widely used.

The ops example is exactly how I would foresee it looking:

async function isCustomType(value: unknown): Promise<value is CustomType> {
    // ...
}

async function assertIsCustomType(value: unknown): Promise<asserts value is CustomType> {
    // ...
}

I understand you can get around it by personally asserting with "as", however I try to avoid doing this if at all possible. I find it can lead to future issues which would have been avoided if a proper assertion function was used instead, particularly in collaborated code

P.S. thank you for all the hard work! I transitioned completely to TypeScript and haven't looked back since

@mhelvens
Copy link

I don't believe the proposed workaround applies for a case like this:

type Obj = { foo?: string };

async function defineFoo(obj: Obj): Promise<asserts obj is Required<Obj>> {
  obj.foo = await getFooValue();
}

const obj: Obj = {};
await defineFoo(obj);
const foo: string = obj.foo;

I'm currently dealing with a case like this, and the proposed feature would be quite nice to have!

The synchronous version of my example already works (Playground Link). We're just unlucky that our case needs to be async. :-)

@LucasPaganini
Copy link

LucasPaganini commented Nov 22, 2021

Workaround Using Higher Order Functions

TL;DR: We can't return Promise<x is T> yet, but we can return Promise<(x: unknown) => x is T>. I made a short video going deeper into this.

Description

I found a workaround for asynchronous type guards/assertion functions that involves returning a synchronous assertion function or predicate function wrapped in a promise. It's not perfect, but I think it's as close as we can currently get to the ideal implementation.

The Goal (not supported in TypeScript yet)

const isStringAsync = 
  async (value: unknown): Promise<value is string> => 
    typeof value === "string"

My Workaround

const isStringAsync = 
  async (value: unknown): Promise<(v: unknown) => v is string> => 
    (v): v is string => typeof value === "string"

const aaa = 1 as number | string | Date
isStringAsync(aaa).then(isString => {
  if (isString(aaa)) {
    aaa // <- aaa: string
  } else {
    aaa // <- aaa: number | Date
  }
})

Real World Use Case

I understand that checking if a value is a string asynchronously is not a good example haha. Here's a better use case for it, based on the example given by @niko278 :

You can only save users to the database if they are valid. For a user to be valid, it needs to meet two criteria:

  1. (sync) The password should have at least 8 characters
  2. (async) The email cannot already belong to another user

Models

interface User {
  email: string
  password: string
}

type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol
}

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol
}

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>

Functions

import { fromPredicateFunction, assertHasProperties, AssertionFunction } from '@lucaspaganini/ts'

const isString = (v: unknown): v is string => typeof v === 'string'
const assertIsString: AssertionFunction<string> = fromPredicateFunction(isString)

const saveUserToDatabase = 
  async (validUser: ValidatedUser): Promise<void> => { ... }

const validateUserAsync = async (value: unknown): Promise<AssertionFunction<ValidatedUser>> => {
  // If we throw an error, save it to throw later, in the assertion function
  let errorToThrow: Error | null = null

  try {
    assertHasProperties(['email', 'password'], value)
    assertIsString(value.email)
    assertIsString(value.password)

    // 1. The password should have at least 8 characters
    if (value.password.length < 8) 
      throw Error('Password is too short')

    // 2. The email cannot already belong to another user
    if (await emailIsAlreadyTaken(value.email)) 
      throw Error('Email is already taken')
  } catch (error) {
    errorToThrow = error
  }

  return v => {
    if (errorToThrow) 
      throw errorToThrow
  }
}

Usage

let user: unknown
const validateUser: AssertionFunction<ValidatedUser> = await validateUserAsync(user)
validateUser(user)
await saveUserToDatabase(user)

TypeScript Utilities Library

I also made a TypeScript utilities library that includes (among other flexible utilities) a higher order function called makeAsyncPredicateFunction. I hope using it will simplify the creation of asynchronous guards and make the refactoring process easier once we get native support for this in TypeScript.

import { makeAsyncPredicateFunction } from '@lucaspaganini/ts'

const isStringAsync = 
  makeAsyncPredicateFunction<string>(
    async value => typeof value === 'string')

const aaa = 1 as number | string | Date
isStringAsync(aaa).then(isString => {
  if (isString(aaa)) {
    aaa // <- aaa: string
  } else {
    aaa // <- aaa: number | Date
  }
})

It's MIT, open-sourced, and available on NPM.

Watch this video to know more.

@JoshuaKGoldberg
Copy link
Contributor

This would be very useful for Temporal signals and queries. wf.condition is an async function that returns when some condition is true, and is commonly used to wait until some variable is truthy.

let forced: string | undefined;

// either of:
await wf.condition /* <forced is string> */ (() => !!forced);
await wf.condition(() /* : forced is string */  => !!forced);

// forced expected type: string
// forced actual type: string | undefined

@ClaudiuCeia
Copy link

I just ran into an issue that would greatly benefit from this in an API project. I want to be able to validate that an ID is of a certain type. All IDs are UUID v4 so I can't tell if an ID is of a user, or of some other entity, without doing a database lookup.

The IDs are nominal so you can't pass a User ID or a string to a function that expects a Foobar ID so being able to narrow types with the built-in guards would be great as they don't leave too much space for error.

So right now I'm torn between:

  1. Using something like @LucasPaganini mentioned (this has the drawback that I need to pass the arguments twice and even if I add a check that both times I passed in the same value, it may not be the same variable)

  2. Having a function that returns Promise<boolean>, leaving me to cast the type at the callsite which is quite error prone and cumbersome

  3. Having a function that takes an unknown and returns an ID<Type> or throws, which is safer (no casting except for the function implementation) but it's a nightmare for control flow where I expect an id that may come from any entity type (like the node resolver in some GraphQL APIs).

So in the end, I'm juggling between option no. 2 and 3, and I don't like either of them. Or I can use a flavor type for IDs, but that has it's own issues in my case.

Hope this helps the team to make a decision. Cheers

@mindplay-dk
Copy link

This proposed syntax is a bit confusing:

async function assertIsCustomType(value: unknown): Promise<asserts value is CustomType> {
    // ...
}

Where is the return-type? The return-type is boolean, but there's no saying boolean or Promise<boolean> are the only types I might want to return after making an assertion.

A return-type declaration such as asserts value is CustomType currently implies a void return-type - again, I don't see any practical reason why assertion functions should be limited to void return-types: an asserts declaration doesn't describe the return-type in the first place, it describes an effect on the type of the symbol in the calling context.

How about fully separating the return-types from the assertion instead?

async function assertIsCustomType(value: unknown): Promise<boolean>, asserts value is CustomType {
    // ...
}

I think this refactors better. It also allows you to return any type you want:

async function assertIsCustomType(value: unknown): Promise<Stuff>, asserts value is CustomType {
    // ...
}

The default behavior of returning void could be explicitly written as:

async function assertIsCustomType(value: unknown): void, asserts value is CustomType {
    // ...
}

This approach would allow making more than one assertion as well:

async function assertThings(a: unknown, b: unknown): asserts a is A, asserts B is B {
    // ...
}

The way I see it, assertions functions currently don't have a defined return-type, because asserts X is Y does not say anything about the return-type at all.

Why should the use of assertions prevent you from declaring a return-type? 🤔

@avin-kavish
Copy link

Would be nice to have this. Currently, you can get a async function marked as a type guard to compile using @ts-ignore. An it does guard as long as you use it without await. But as soon as you await it (or use the return value), the guard breaks.

async function loadRef<T>(
  value: ManagedReference<T> | null,
// @ts-ignore
): asserts value is NonNullable<ManagedReference<T>> {}

// guards
loadRef(f.car)
console.log(f.car.color)

// does not guard
await loadRef(f.car)
console.log(f.car.color)

@steveluscher
Copy link

I have need of this too, because the assertion I want to make has to be done with async browser APIs, namely SubtleCrypto#verify.

I want to make a function that verifies that a particular object is a ‘fully signed transaction.’ It's not important what this means, it's only important to know that I have to operate over that object with an async method.

// Pseudo-code, for the sake of brevity.
async function assertIsFullySignedTransaction(
  tx: BaseTransaction & ITransactionWithSignatures,
): asserts tx is IFullySignedTransaction {
  await Promise.all(tx.signers.map(async signer => {
    const signatureDoesMatch = await crypto.subtle.verify(signer, /* ... */);
    if (signatureDoesMatch === false) {
      throw new Error(`Signature verification failed for ${signer}`);
    }
  }));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests