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

By extend transitivity, class instances should extend Record<string, any> #57285

Closed
denis-migdal opened this issue Feb 4, 2024 · 16 comments
Closed
Labels
Not a Defect This behavior is one of several equally-correct options

Comments

@denis-migdal
Copy link

πŸ”Ž Search Terms

classes instances Record
extend transitivity

πŸ•— Version & Regression Information

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

⏯ Playground Link

Playground Link

πŸ’» Code

class Foo {

	foo = 4;
}
type T1 = Foo extends Record<string, Foo[keyof Foo]> ? true: false; // false : expected true

type RecordFoo = Record<keyof Foo, Foo[keyof Foo]>;
type T2 = Foo extends RecordFoo ? true: false; // true
type T3 = RecordFoo extends Record<string, Foo[keyof Foo]> ? true: false; // true

type AsRecord<C> = {
	[K in keyof C]: C[K]
}
type FooAsRecord = AsRecord<Foo>
type T4 = Foo extends FooAsRecord ? true: false; // true
type T5 = FooAsRecord extends Record<string, Foo[keyof Foo]> ? true: false; // true

πŸ™ Actual behavior

Foo doesn't extend Record<string, Foo[keyof Foo]>, however, it can extend types that extend it.
Therefore by transitivity, Foo should extend Record<string, Foo[keyof Foo]> ?

πŸ™‚ Expected behavior

Foo should extend Record<string, Foo[keyof Foo]>.

Additional information about the issue

This issue prevents doing the following:

function faa<T extends Record<string, number>>(a: T) {
     // ...
}
faa(new Foo()); // error.

let foo: AsRecord<Foo> = new Foo();
faa(foo); // no error

let foo2: Record<keyof Foo, Foo[keyof Foo]> = new Foo();
faa(foo2); // no error
@jcalz
Copy link
Contributor

jcalz commented Feb 4, 2024

#47499

Transitivity of assignment is violated in a number of places in the language, which isn’t guaranteed to be sound. Such violations are unfortunate but intentional consequences of different desirable behaviors interacting poorly.

@denis-migdal
Copy link
Author

Transitivity of assignment is violated in a number of places in the language, which isn’t guaranteed to be sound. Such violations are unfortunate but intentional consequences of different desirable behaviors interacting poorly.

I made another test :

class Foo {

   foo = 4;
}
type T1 = Foo extends Record<string, Foo[keyof Foo]> ? true: false; // false

const obj = {
   foo: 4
};
type T2 = typeof obj extends Record<string, Foo[keyof Foo]> ? true: false; // true

Shouldn't an instance of Foo be somehow equivalent to obj ?

Currently, the workaround to this issue is:

function faa<T, U extends Record<...> = AsRecord<T> extends Record<...> ? AsRecord<T> : never>(args: U) {
}

Can't it be possible to make TS internally/implicitly do it ?

@jcalz
Copy link
Contributor

jcalz commented Feb 4, 2024

See #15300. Interfaces (including instances of class declarations) do not get implicit index signatures, while anonymous types (including types inferred for object literals) do. This difference is intentional, and they're not going to change it.

@denis-migdal
Copy link
Author

See #15300. Interfaces (including instances of class declarations) do not get implicit index signatures, while anonymous types (including types inferred for object literals) do. This difference is intentional, and they're not going to change it.

I'm talking about classes, not interfaces. IIRC classes doesn't have declaration merging.

Also, an object works while keeping types on each attributes:

let foo = {
    a: 2,
    b: "str"
}

let foo = {
    a: 2,
    b: "str"
}

foo.b = 3; // error.

So if it works for objects, why wouldn't it be possible to make it works with a class instance too ?

@jcalz
Copy link
Contributor

jcalz commented Feb 4, 2024

The instance side of a class is equivalent to an interface.

Your object type example doesn't involve index signatures so it's a different feature entirely.

I think I'm going to disengage now; I'm not a member of the TS team, just giving you a heads up as to what's likely going to happen here.

@denis-migdal
Copy link
Author

Your object type example doesn't involve index signatures so it's a different feature entirely.

? but still we have:

type T1 = Foo extends Record<string, Foo[keyof Foo]> ? true: false; // false
type T2 = typeof obj extends Record<string, Foo[keyof Foo]> ? true: false; // true

@Remco0570
Copy link

Uitschrijven wil niet

@rotu
Copy link

rotu commented Feb 5, 2024

The assumption that extends is transitive is wrong. But there is something I deeply don't understand here.

Why does Record<string, number> extends Record<"A", number> but NOT { [K in string]: number } extends { [K in 'A']: number }?

// @target: ES2022

// adding more keys makes a Record type more specific
type Ex0 = Record<'A', number> extends Record<never, number> ? true : false
//   ^?
type Ex1 = Record<'A'|'B', number> extends Record<'A', number> ? true : false
//   ^?
type Ex2 = Record<string, number> extends Record<('A'|'B'), number> ? true : false
//   ^?

type R = { [K in never]: number }
type RA = { [K in 'A']: number }
type RAB = { [K in ('A'|'B')]: number }
type RS = { [K in string]: number } // could also write this as { [R in 'A'|'B'|string]: number } 

// adding more keys makes an Object type more specific
type Ex3 = RA extends R ? true : false
//   ^?
type Ex4 = RAB extends RA ? true : false
//   ^?
// until we get to [R in string] ? WHAT?
type Ex5 = RS extends RAB ? true : false
//   ^?

Workbench Repro

@denis-migdal
Copy link
Author

denis-migdal commented Feb 5, 2024

The assumption that extends is transitive is wrong. But there is something I deeply don't understand here.

Why does Record<string, number> extends Record<"A", number> but NOT { [K in string]: number } extends { [K in 'A']: number }?

I tried to find a cleaner workaround for my issue, and I think I got something related to your question:

{
	type AsRecord<T> = {
		[K in keyof T]: T[K]
	}

	type ReverseAsRecord<T> = T extends AsRecord<infer C> ? C : never;

	type Record_v2<K extends string,V> = ReverseAsRecord<Record<K,V>>;

	function foo<T extends Record_v2<string, any>>(t: T): T { throw new Error() }
	function faa<T extends Record_v2<string, number>>(t: T): T { throw new Error() }
    function fuu<T extends Record_v2<"foo", number>>(t: T): T { throw new Error() }


	class C {
		foo!: number;
	}

	foo(new C()); // ok
	faa(new C()); // nok, why ??? I'm sad :'(
	fuu(new C()); // ok

	foo({foo: 3}) // ok
	faa({foo: 3}) // ok
	fuu({foo: 3}) // ok
}

@RyanCavanaugh RyanCavanaugh added the Not a Defect This behavior is one of several equally-correct options label Feb 5, 2024
@typescript-bot
Copy link
Collaborator

This issue has been marked as "Not a Defect" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Feb 8, 2024
@rotu
Copy link

rotu commented Feb 8, 2024

Why does Record<string, number> extends Record<"A", number> but NOT { [K in string]: number } extends { [K in 'A']: number }?

BTW, I think this is #48070.


Also, from experimentation, I'm having a hard time figuring out where it actually makes sense to use a {[x:string]:T} or Record<string, T> except in an intersection with some object type or as a constraint on some type variable.

What were you originally trying to accomplish?

@denis-migdal
Copy link
Author

What were you originally trying to accomplish?

I need a generic object (i.e. with values and a generic type). The only way to do it is to use a generic class (both a value and a type). This need is due to the fact base class expressions cannot references class type expressions.

class Events<T> {
	CHANGED = EventImpl<[T|null,T|null]>;
}

type AsRecord<C> = {
	[K in keyof C]: C[K]
}

class WithEvents<T, I extends EventsCstrs = AsRecord<T> extends EventsCstrs ? AsRecord<T> : never> {

	constructor(events: Constructor<I>) {
		this._Events = createEventsManager(this, new events() );
	}

	protected _Events;

	get Events(): inferEventsSourcesType<typeof this._Events> {
		return this._Events as any;
	}
}

export class Test<T> extends WithEvents<Events<T>> {

	constructor() {
		super(Events);
	}
}

@rotu
Copy link

rotu commented Feb 11, 2024

Ah okay. So the motivating issue is, I'm thinking, in the definition of Constructor.

That is, I bet it looks something like:

type Constructor<EventTypes extends Record<string, unknown>>

And it should look something like this:

type Constructor<EventTypes extends object>

// or if you need some fancier restrictions:
type Constructor<
  EventTypes extends Record<EventNames, EventValues>,
  EventNames extends string = string & keyof EventTypes,
  EventValues extends SomeBaseEventType,
>

@denis-migdal
Copy link
Author

Ah okay. So the motivating issue is, I'm thinking, in the definition of Constructor.

No there isn't any issue with Constructor.
The issue is that I need to write:

 WithEvents<T, I extends EventsCstrs = AsRecord<T> extends EventsCstrs ? AsRecord<T> : never>

instead of simply writing:

WithEvents<I extends Record<string, ...>>

But indeed, your solution seems to be working :

class Event<T>{}

class X {
   foo!: Event<string>;
   faa!: Event<number>;
}

function foo<
 EventRecord extends Record<EventNames, Event<any>>,
 EventNames  extends keyof EventRecord = keyof EventRecord,
>( events: Constructor<EventRecord> ) {
   return new events();
}

let ev = foo(X);
ev.foo // Event<string>

I didn't knew we could make some circular references like that.

Could be great to have a kind of "list of known workarounds" in the doc.

@rotu
Copy link

rotu commented Feb 11, 2024

Got it. I'm surprised it even works too :-). You don't even need an intermediate type parameter! An extends constraint can refer to its own type constraint variable:

function foo<T extends object & {[K in keyof T]:Event<any>}> (t: T){
    // ...
}

But there are some restrictions on this technique:

// Error: Type parameter 'T' has a circular constraint.
function foo<T extends object & T> (t: T){
}

@denis-migdal
Copy link
Author

Oh, I didn't knew. Indeed it works:

function foo<T extends {[K in keyof T]:Event<any>}> (t: Constructor<T>){
    return new t();
}
function foo<T extends Record<keyof T,Event<any>>> (t: Constructor<T>){
    return new t();
}

I really think such trick ought to be written somewhere

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Not a Defect This behavior is one of several equally-correct options
Projects
None yet
Development

No branches or pull requests

6 participants