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

TS 3.7: unlike x is T, asserts x is T cannot close over generics defined in outer scopes #34596

Open
mweststrate opened this issue Oct 19, 2019 · 11 comments
Labels
Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Milestone

Comments

@mweststrate
Copy link

TypeScript Version: 3.7.0-dev.20191016

Search Terms:
asserts, asserts return, asserts higher order, asserts type, 2775

Code

function literal<T extends keyof any>(lit: T): {
    is(value: any): value is T
    assert(value: any): asserts value is T
} {
    return null as any; // implementation doesn't matter
}

const isHi = literal("hi")
const x: unknown = "test"

if (isHi.is(x)) {
    console.log(x) // x is correctly inferred to be 'hi' :)
}

isHi.assert(x); // error: Assertions require every name in the call target to be declared with an explicit type annotation.(2775)
console.log(x); // x should be inferred to be 'hi' here :(

Expected behavior:

No compile error, x is inferred to be "hi" on the last line.

Actual behavior:

Compile error on isHi.assert. Assertions require every name in the call target to be declared with an explicit type annotation.(2775)

Construction higher order type guards is possible without problem (as shown in the snippet). This mechanism used heavily in libraries like io-ts and mobx-state-tree.

However, when trying to extend the latter library with assertion functionality for more convenient control flow, we run into this issue.

We can build type.is properly, but not type.assert, although they seem to be needing the exact same type / depth of type analysis; if type guards can close over T, so should type assertions?

Playground Link: link

Related Issues:

#34523

@jcalz
Copy link
Contributor

jcalz commented Oct 20, 2019

This is, unfortunately, working as intended as per #33622, see #33580. The issue is not about closing over generics, but about the fact that your isHi variable is not typed via explicit type annotation. The error message says as much, I guess, but it is bewildering, and I expect you won't be the last person bewildered by it. The fix is something like:

interface Lit<T extends keyof any> {
    is(value: any): value is T;
    assert(value: any): asserts value is T;
}

function literal<T extends keyof any>(lit: T): Lit<T> {
    return null as any;
}

const isHi: Lit<"hi"> = literal("hi")

Playground Link

in which you are forced to write out a type like Lit<"hi">.


This is clearly going to be a pain point with user-defined assertion functions once 3.7 is out of beta. Is the current error message really the best we can do? Anyone have any great ideas? I was hoping the error would come with a "quick fix" that suggests some suitable annotation somewhere, but I guess that would be too hard to get right (if the compiler could always figure out what the annotation should be I guess the annotation wouldn't be necessary in the first place).

@mweststrate
Copy link
Author

mweststrate commented Oct 20, 2019 via email

@mweststrate
Copy link
Author

mweststrate commented Oct 20, 2019

To elaborate a bit more, how this functionality in general is used, is like:

import { types } from "mylib"

const User = types.Object({
  name: types.string,
  tags: types.array(types.union(types.literal("happy"), types.literal("sad")))
})

// Later
if (User.is(someData)) {
  // someData is correctly inferred to be of type { name: string, tags: ("happy" | "sad")[] }
}
User.assert(someData) // TS Error

This all works splendidly well for typeguards, however, forcing the user to type User explicitly to be able to make asserts as well, partially defeats the purpose of library, as these types get really large in practice (the demo above is still really small).

What is more, to add to the inconsistency (see snippet below), if assertion is extracted as utility function that receives the type as generic argument, it works fine. So it seems that in principle the TypeChecker in principle doesn't have any trouble checking this, but that there is just a hard limit (magic number) put in place somewhere?

type Guard<T> = { is(value: any): value is T }

function assert<T>(t: Guard<T>, value: any): asserts value is T {
    // something
}

assert(isHi, x)

console.log(x); // x is correctly as 'hi' over here

playground

Edit: I am not sure how this relates to CFA; as to infer the type of isHi only the function signatures matter, not the internal flow for how or when that object was constructed?

@jack-williams
Copy link
Collaborator

What is more, to add to the inconsistency (see snippet below), if assertion is extracted as utility function that receives the type as generic argument, it works fine.

This is consistent in that assert has an explicit type signature. As @jcalz's says, polymorphism is orthogonal to the cause of the issue - though it does exacerbate to pain because instantiated generic types are usually harder to write explicit types for.

@RyanCavanaugh RyanCavanaugh added Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript labels Oct 30, 2019
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 3.8.0 milestone Oct 30, 2019
@RyanCavanaugh
Copy link
Member

The issue here is that we need the information about the assertness of a function early enough in the compilation process to correctly construct the control flow graph. Later in the process, we can detect if this didn't happen, but can't really fix it without performing an unbounded number of passes. We warned that this would be confusing but everyone said to add the feature anyway 🙃

I think it would be tractable to produce a quick fix in many places, since we (should?) know the originating declaration, know how to write the type down (if possible) in the context of that declaration.

ostrowr added a commit to ostrowr/ts-json-validator that referenced this issue Nov 9, 2019
Usage of parser.validate is unclear because you need to assign an output type, which defeats the
purpose of this whole library (see microsoft/TypeScript#34596 and related
issues.) This changes .validate (which throws on invalid input) to .validates, which is a type guard
that returns false on invalid input. .parse still throws on invalid input.

BREAKING CHANGE: TsjsonParser.validate renamed to TsjsonParser.validates and functionality changed
from assertion function to type guard.
ostrowr added a commit to ostrowr/ts-json-validator that referenced this issue Nov 9, 2019
…rd (#13)

Usage of parser.validate is unclear because you need to assign an output type, which defeats the
purpose of this whole library (see microsoft/TypeScript#34596 and related
issues.) This changes .validate (which throws on invalid input) to .validates, which is a type guard
that returns false on invalid input. .parse still throws on invalid input.

BREAKING CHANGE: TsjsonParser.validate renamed to TsjsonParser.validates and functionality changed
from assertion function to type guard.
@Zemnmez
Copy link

Zemnmez commented Nov 9, 2019

I don't really understand much of this but I tried this today and couldn't make it work. is the takeaway that assertions won't work with polymorphism?

@ianstormtaylor
Copy link

ianstormtaylor commented Dec 20, 2019

Ran into this exact thing while trying to see how Superstruct could be made more useful for TypeScript and automatically infer the return types whenever people constructed validations.

@ianstormtaylor
Copy link

@RyanCavanaugh I wanted to add a weird behavior that I encountered about this... It seems like there's a difference in this behavior across package boundaries.

For example, given package 'a' which creates an "asserter":

export interface Asserter<T> {
  assert(x: unknown): asserts x is T;
}

export const create = <T>(): Asserter<T> => null as any;
export const number = () => create<number>();
export const Number = number();

If you import it in package 'b' the assertion works only if you use the pre-defined asserter, and don't create a new one from the factory:

import { Number, number } from "a";

{
  const x: unknown = 42;
  Number.assert(x);
  x;
  // This one works fine, and `x` is known to be of type `number`.
}

{
  const x: unknown = 42;
  const N = number();
  N.assert(x);
  // (method) Asserter<number>.assert(x: unknown): asserts x is number
  // Assertions require every name in the call target to be declared with an 
  // explicit type annotation.ts(2775)
  // index.ts(12, 9): 'N' is declared here.
  x;
}

Here's the reproduction as a repo to clone.

Even further, if you actually inline the logic from package 'a' as a file inside package 'b' and import it with './a', both of the examples will error.


I kind of assume this is "expected" given the internal limitations of how this feature is written, but externally it feels weird and adds confusion.

For an example of confusion in the wild, there's a library called runtypes which has added an .assert method to its validators, and currently it has this halfway-working behavior. For example:

import { Number, Array } from 'runtypes'

const ArrayOfNumbers = Array(Number)

Number.assert(x) // works!
ArrayOfNumbers.assert(x) // fails!

@mmkal
Copy link

mmkal commented Sep 12, 2020

We can't really fix it without performing an unbounded number of passes. We warned that this would be confusing but everyone said to add the feature anyway 🙃

@RyanCavanaugh I won't pretend I fully understand what a "pass" is, but was it considered to "count" the number of "passes" and throw an error similar to the Type instantiation is excessively deep and possibly infinite if there are more than some reasonable limit? I'm not sure whether that would help, I don't know how many passes the examples in this issue would require, for example.

@mmkal
Copy link

mmkal commented Sep 12, 2020

It's possible to workaround this in a strange way, by assigning the assertion function to itself, with a redundant type annotation. Example with io-ts:

import * as t from 'io-ts'

export const getAsserter = <A>(type: t.Type<A>) => (val: unknown): asserts val is A => {
  if (!type.is(val)) {
    throw Error('Invalid input')
  }
}

const _assert = getAsserter(t.type({foo: t.string}))
const assert: typeof _assert = _assert

export const printFoo = (x: unknown) => {
  console.log(x.foo) // error: Object is of type 'unknown'
  assert(x)
  console.log(x.foo) // ok: x now has type {foo: string}
}

👆 the above breaks if you remove the pointless-looking type annotation in const assert: typeof _assert = _assert.

Playground link

thetutlage added a commit to adonisjs/http-server that referenced this issue Feb 12, 2021
The current signature of the abortUnless method doesn't work. Here is a related
typescript issue for it microsoft/TypeScript#34596
@alecgibson
Copy link

alecgibson commented Feb 28, 2023

Came here also wanting to work with io-ts, so building on @mmkal 's answer, you can make calling code a tiny bit prettier by naming and inlining the (redundant) asserter type definition:

import * as t from 'io-ts'

export const getAsserter = <A>(type: t.Type<A>) => (val: unknown): asserts val is A => {
  if (!type.is(val)) {
    throw Error('Invalid input')
  }
}

export type Asserter<T> = ReturnType<typeof getAsserter<T>>;

Then in some other module:

const Foo = t.type({foo: t.string})
type IFoo = t.TypeOf<typeof Foo>;
const assert: Asserter<IFoo> = getAsserter(Foo);
assert(foo);
foo.foo // ok
// @ts-expect-error :: invalid property
foo.bar // not ok

EDIT: Realised the above code still isn't ideal, because you can instantiate Asserter<T> with any type parameter and apparently TypeScript doesn't mind that 🤷🏼 (eg this compiles: const assert: Asserter<string> = getAsserter(Foo))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

8 participants