TypeScript: Dangerous calls allowed by TS: class generic type, when `unknown`, should be treated as `never` in the class methods/callback parameters

šŸ”Ž Search Terms

unknown never generic type methods

šŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about _________

āÆ Playground Link

Playground Link

šŸ’» Code

// allows dangerous calls
{
    class X<T> {
        callback(t: T) {}
    }

    const xb = new X<boolean>();

	const x: X<unknown> = xb; // ok
	x.callback(34); // dangerous.
}
// can't assign if callback
{
    class X<T> {
		callback!: (t: T) => void
	}

    const xb = new X<boolean>();
	const x: X<unknown> = xb; // nok
	x.callback(34); // dangerous
}
// proposal
{
    class X<T> {
        callback(t: T extends unknown ? never : T) {}
    }

    const xb = new X<boolean>();

	const x: X<unknown> = xb; // ok
	x.callback(34); // error (good)
}

šŸ™ Actual behavior

When a class generic type is unknown, if used as parameters in a method/callback, will authorize dangerous calls :

(x as X<unknown>).callback(34); // we don't know the generic type of x so we should assume it is unsafe to call this method.

Also, due to that, if the class has a callback, we can’t assign X<...> to X<unknown>.

šŸ™‚ Expected behavior

TS should assume the method parameter type is never to prevent its call, until the user has asserted the class real type.

// proposal
{
    class X<T> {
        callback(t: T extends unknown ? never : T) {}
    }

    const xb = new X<boolean>();

	const x: X<unknown> = xb; // ok
	x.callback(34); // error (good)
}

Additional information about the issue

This would also enable to use unknown instead of any in some cases :

// use unknown instead of any to not trigger LINT warning.
function foo<T extends Record<string, X<unknown>>>() {

}

Current workaround would be :

function foo<T extends Record<string, X<U>>, U>() {

}

About this issue

  • Original URL
  • State: closed
  • Created 4 months ago
  • Comments: 25 (9 by maintainers)

Most upvoted comments

@denis-migdal I’m just going to ask that you please not learn TypeScript’s various ins and outs via filing bugs on the issue tracker.

I’m sorry for that, but you’ll have to admit that TS has LOT of undocumented unsoundness, at a point you really should have a whole ā€œDesigns choices and intentional unsoundnessā€ chapter in the documentation…

Some features are even undocumented, like in and out annotations, only talked about in the 4.7 announcement. With such feature, it should either (from best to worst) :

  • TS deduces the in/out by itself and explicit annotation is used to change this behavior.
  • every generic be in out by default.
  • every generic requiring explicit in/out annotations

But instead of introducing some compiler flag(s), TS introduced unsoundness instead and again… This is a living hell of undesirable undocumented behavior… just to dirty-solve some specific cases, when more control on the compiler flags would do the trick…

If you have some behavior that also repros in 3.3, it’s 99.99% certain that it’s not a longstanding bug that’s just been overlooked for 5+ years.

If if walk like a bug, and quack like a bug, it is very likely a bug. You can call intentional bugs ā€œintentional unsoundnessā€, it still remains a bug (more specifically a flow or a fault in the design).

How many big issues are still open and has been overlooked for 5+ years, while having lot of duplicates ?

? I do not think TS gets the annotations right in these cases:

Ah, right. I’ve misinterpreted my quick tests. This has already been mentioned in the thread - but well, methods are bivariant in TS. You might not agree with this decision but it’s here to stay. You can always create a linting rule to avoid the method syntax altogether. This behaves like you expect it to:

class X<T> {
  t!: T;

  callback = (t: T) => {
    this.t = t;
  };
}

const _a: X<unknown> = {} as X<boolean>; // forbidden (ok)

This is, in fact, exactly what happens. The annotations were added later because TS sometimes gets it wrong.

When TS gets it wrong on very basic examples… is it really the case ?

class X<T> { // should be "in"...
    #t!: T;

    callback(t: T) {

        this.#t = t; // even without that, this should be "in"
    }
}

At this point, better having a compiler option to enable the second behavior : everything is in out by default.

It’s not a fault in the design if soundness was never a design goal in the first place (and this fact is documented).

The fact that bugs are called ā€œunsoundnessā€ without documenting them is a fault in the design. As I already stated, I can understand little unsoundness on edges cases due to the internal implementation of things. But, by default, allowing implicit Dog[] -> Animal[] cast isn’t an unsoundness, this is a dangerous behavior.

This should:

  • only allow implicit Dog[] -> readonly Animal[] cast
  • else, requires an explicit cast
  • else, compiler options

The same for cast with readonly properties.

if that’s unsuitable for your use, you’ll need to look for a different tool.

If only this different tool existed…

@denis-migdal I’m just going to ask that you please not learn TypeScript’s various ins and outs via filing bugs on the issue tracker. If you have some behavior that also repros in 3.3, it’s 99.99% certain that it’s not a longstanding bug that’s just been overlooked for 5+ years.

So its about the target side requiring more properties than the source has - not about per property modifiers difference

Indeed.

Still, I don’t understand why TS isn’t doing such very basic check. We can very easily implement our own system, and not much needs to be added to TS to make it cleaner and more generic:

const RW = Symbol("");

class X {
    private [RW] = true; // cleaner if automatically added in classes def, and removed from generated JS code.

    foo_ro() {}
    faa_rw(){}
}

type RO<T> = Readonly<{
    // cleaner if condition would be the presence of a flag in the method.
    [K in keyof Omit<T, typeof RW> as K extends `${infer _X}_ro` ? K : never ]: T[K]
}>

let x: RO<X> = new X();
x.foo_ro();
x.faa_rw();
let y: X = x;