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

Permit type alias declarations inside a class #7061

Open
NoelAbrahams opened this issue Feb 12, 2016 · 82 comments
Open

Permit type alias declarations inside a class #7061

NoelAbrahams opened this issue Feb 12, 2016 · 82 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@NoelAbrahams
Copy link

I normally want my class declaration to be the first thing in my file:

module foo {

  /** My foo class */
  export class MyFoo {

  }
}

So when I want to declare an alias, I don't like the fact that my class has been pushed down further:

module foo {

 type foobar = foo.bar.baz.FooBar;

  /** My foo class */
  export class MyFoo {

  }
}

I suggest the following be permitted:

module foo {

  /** My foo class */
  export class MyFoo {
    type foobar = foo.bar.baz.FooBar;
  }
}

Since type foobar is just a compile-time construct, and if people want to write it that way, then they should be permitted to do so. Note that the type foobar is private to the class. It should not be permissible to export foobar.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 12, 2016

What scenarios would this enable other than putting it outside the class? it is not expected to be accessible on the class instance/constructor for instance?

@NoelAbrahams
Copy link
Author

It's just a way to deal with long-winded namespaces inside a class. No, I wouldn't expect the class to act as a scope for this.

Perhaps #2956 will solve this better. I was just (sort of) thinking aloud here.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 12, 2016

This looks like a things applicable within a namespace or a function. a class defines a shape of an object rather than a code container, so i would not allow a top-level declarations within the body of a class that does not contribute to the "shape" of the instance of the constructor function.

@NoelAbrahams
Copy link
Author

NoelAbrahams commented Feb 12, 2016

Yes, a class is not a container. It's just for use within the class. I'm not realy sure that I want to defend this ? I just realised that the use case I'm looking at actually requires the type in a value position.

@RyanCavanaugh
Copy link
Member

You could do this, which isn't terribly great but isn't horrible either:

module foo {
  /** My foo class */
  export class MyFoo {
      x: MyFoo.foobar;
  }
  namespace MyFoo {
      export type foobar = string;
  }
}

@NoelAbrahams
Copy link
Author

@RyanCavanaugh, I agree. That would be the best second choice. I think I've used it a couple of times, but found that positioning code at the bottom of the file a bit disconcerting. I just like the idea of having just the one class per file with no other distractions.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 19, 2016

The scenario should be handled by the existing type alias declarations outside the class, or using a namespace declaration as outlined in #7061 (comment).

Adding a declaration on the class that does not appear on the type does not fit with the current pattern. I am inclined to decline this suggestion, feel free to reopen if you have a different proposal that addresses the underlying scenario.

@mhegazy mhegazy closed this as completed Feb 19, 2016
@mhegazy mhegazy added Suggestion An idea for TypeScript Declined The issue was declined as something which matches the TypeScript vision labels Feb 19, 2016
@malibuzios
Copy link

I'm running into this pattern:

I have a generic class and would like to create a type alias that references a specialized generic type expression that is only valid for that particular class scope, for example instead of:

class GenericClass<T> {
  func1(arr: Array<{ val: T }>):  Array<{ val: T }> {
    ..
  }

  func2(arr: Array<{ val: T }>):  Array<{ val: T }> {
    ..
  }
...
}

It would be great if I could alias that complex type expression (Array<{ val: T }>) to a locally scoped type. e.g.

class GenericClass<T> {
  type SpecializedArray = Array<{ val: T }>;

  func1(arr: SpecializedArray): SpecializedArray {
    ..  
  }

  func2(arr: SpecializedArray): SpecializedArray {
    ...
  }
}

I'm not exactly sure how to effectively work around this. Both the solutions provided @RyanCavanaugh and the original one mentioned by @NoelAbrahams would still a require to parameterize the type alias. E.g:

type SpecializedArray<T> = Array<{ val: T }>;

But that's not really what I'm looking for.. The whole idea was to make it simpler and more readable.. (also, if part of the workaround meant I had to use some strange merging between a class and a namespace I would rather just have nothing at all and write all the types verbosely).

@malibuzios
Copy link

@RyanCavanaugh @mhegazy

Please consider reopening this issue. Here's a copy-and-paste fragment of real-world code I'm working on that demonstrates the usefulness of a generic type captured into a locally scoped type alias to yield simpler and a more readable type:

export class BrowserDB<V> {
    ...
    set(valueObject: { [key: string]: V }, timestamp?: number): Promise<void> {
        type EntryObject = { [key: string]: DBEntry<V> };

        return PromiseX.start(() => {
            if (timestamp == null)
                timestamp = Timer.getTimestamp();

            let entriesObject: EntryObject = {};
            let localEntriesObject: EntryObject = {};

            for (let key in valueObject) {
                entriesObject[key] = {
                    timestamp: timestamp,
                    key: key,
                    value: valueObject[key]
                }

                localEntriesObject[key] = {
                    timestamp: timestamp,
                    key: key,
                    value: undefined
                }
            }
        ...

Unfortunately today I cannot capture { [key: string]: V } into a type alias that is scoped to the class and captures V, e.g instead of:

set(valueObject: { [key: string]: V }, timestamp?: number): Promise<void> {

Write:

export class BrowserDB<V> {
    ...
    type ValueObject = { [key: string]: V };

    set(valueObject: ValueObject, timestamp?: number): Promise<void> {
       ...
    }
...

@mhegazy
Copy link
Contributor

mhegazy commented Mar 24, 2016

This could have easily been written as a type alias outside the class, without sacrificing readability or convenience.

type EntryObject<V> = { [key: string]: DBEntry<V> };
class BrowserDB<V> {
  set(valueObject: EntryObject<V>, timestamp?: number): Promise<void> {
  }
}

@malibuzios
Copy link

@mhegazy

The idea is that the generic parameter is captured within the alias itself, reducing EntryObject<V> to EntryObject and cleaning up the code a bit. This is perhaps not an ideal example. There could also be situations where there are multiple generic parameters like:

EntryObject<KeyType, ValueType, MyVeryLongClassName>

etc. and then the impact on the readability of the code would be more apparent.

The fact that I did not provide an example with multiple and long named type parameters does not mean that people aren't encountering this, and couldn't benefit from having this in some cases. My example was honest as I simply don't have much code that uses many type parameters and longer name for the type name provided.

Another advantage is that having the alias as local to the class would not 'contaminate' a larger scope, that may have usage of a similar alias. That seems like a basic design principle of type aliases, so it is not really applied to its fullest here.

@mhegazy
Copy link
Contributor

mhegazy commented Mar 24, 2016

Still do not see that this warrants breaking the consistency of a class enclosure as noted in #7061 (comment).

@malibuzios
Copy link

@mhegazy

First, thank you for taking note of my request to at least try to reconsider this.

I've read the comment you linked but did not completely understand it. I'm not sure why the concept of a type declaration should be seen as bounded by Javascript scoping and as this is purely a TypeScript meta-syntax that does not relate to any run-time behavior, and removed when code is compiled.

I'm afraid I'm not sufficiently familiar with the compiler code and the intricacies of the design here, but that's basically all I can see from my viewpoint.

@mhegazy
Copy link
Contributor

mhegazy commented Mar 24, 2016

it is not the compiler. it is more of a question of syntax consistency.

Declarations inside a class body are only constructor, index signature, method, and property declarations. A class body can not have statements, or other declarations. all declarations inside a class body "participate" in the shape of the class either instance or static side. also all declarations inside the class are accessed through qualification, e.g. this.prop or ClassName.staticMethod.

having said that, i do not see a type alias declaration fit with this crowd. and allowing it would be fairly odd. there is value in consistency and harmony in the a programming language syntax, and i do not think the convenience of not writing a type parameter warrants breaking that.

@malibuzios
Copy link

@mhegazy

I actually see it as very natural and it is very strange (surprising?) for me to hear it described this way. I think I have a strong conceptual separation between run-time syntax and meta-syntax. I see no problem, functional or aesthetic in having types in this context!

I also see a generic type scoping pattern:

class GenericClas<T, U> {
}

Now, since T and U are types and scoped to the class. Your sort of view would look at this and say, "classes shouldn't have generic parameters because generic parameters do not really fit with the class concept": they are not accessed with this etc.

Since generic parameters are type "aliases" in a broad sense, I see no conceptual difference between them and class-scoped type aliases in this context. They are very similar in the sense that both are notation for a type that is scoped only to the class. It looks very natural for me that aliases would be possible in this particular scope. In any case, since there are already types that can be scoped to a class, adding more does not seem to me to introduce anything novel or even special to me.

Anyway, If the TypeScript is not interested in having class-scoped type aliases, then I guess there's nothing I can do. It's your language and you are the ones who are responsible for it and get paid to improve it. The only thing I can say is that the reasoning here seems very subjective and somewhat arbitrary.

@zpdDG4gta8XKpMCd
Copy link

related #2625

consider the following hack that enables what you want (not exactly in a class but very close to it)

export function toInstanceOfMyClassOf<A, B>() {

    type C = A | B;

    return new class {
       public doThis(one: A, another: B) {
       }
       public doThat(value: C) {
       }
    };

}

@huan
Copy link

huan commented Mar 15, 2018

+1

@sztomi
Copy link

sztomi commented Mar 21, 2018

Here's an example where this feature would be very useful.

export class State<Constant, Trigger> {
  private _transitions: Map<Trigger,
                            Transition<State<Constant, Trigger>, Trigger>>;

  constructor(public value: Constant) {
    this._transitions = new Map<Trigger,
                                Transition<State<Constant, Trigger>, Trigger>>();
  }

could become

export class State<Constant, Trigger> {
  type TransitionT = Transition<State<Constant, Trigger>, Trigger>;
  type MapT = Map<Trigger, TransitionT>;

  private _transitions: MapT;

  constructor(public value: Constant) {
    this._transitions = new MapT();
  }

I'm sorry to see this proposal was declined because a good example was not provided on time. I hope this will be reconsidered.

@InExtremaRes
Copy link

+1

@RyanCavanaugh
Copy link
Member

The provided example is actually pretty illuminating - type aliases currently can't close over other type parameters. Is the intent that you could (outside the class body) refer to State<Foo, Bar>.MapT ?

@zpdDG4gta8XKpMCd
Copy link

@RyanCavanaugh look what you made me do: #18074

@sztomi
Copy link

sztomi commented Mar 23, 2018

@RyanCavanaugh Yes, exactly (and I think the possibility to avoid repetition improves the robustness of generic code like this: if I change the definition of MapT that change will carry over to wherever it's used).

@RyanCavanaugh
Copy link
Member

Today classes don't introduce a namespace meaning, and namespaces are always spellable with bare identifiers and don't require the possibility of type parameters. So this would be really quite a large change to just add sugar to do something you can already do today with a namespace declaration and slightly more type arguments.

johngeorgewright added a commit to johngeorgewright/plugola that referenced this issue Jun 8, 2021
Had to re-implement the long types dues to a lack of type aliasing in
classes. See microsoft/TypeScript#7061
@Right10
Copy link

Right10 commented Jul 2, 2021

I can write like this, but I feel ugly :(

export const ClassA =(function<T extends {s1:string, n1:number}>() {
  type T2=Pick<T, 's1'>

  return class {
    t2:T2 ={s1:'123'};
  }

})()

const classA =new ClassA();
console.log(classA.t2)

@Kyasaki
Copy link

Kyasaki commented Jul 23, 2021

This is an ugly piece of code, and a good reason to implement type aliases or equivalent for classes and interfaces, as it is now implemented for functions:

export interface Selector<TContext, TMetadata, TConstruction> {
	validate(parser: Parser<TContext>): SelectorValidation<TContext, TMetadata, TConstruction>
	revalidate(validation: PartialSelectorValidation<TContext, TMetadata, TConstruction>, parser: Parser<TContext>): SelectorValidation<TContext, TMetadata, TConstruction>
	construct(validation: CompleteSelectorValidation<TContext, TMetadata, TConstruction>, parser: Parser<TContext>): TConstruction
	onEndOfStream?(validation: PartialSelectorValidation<TContext, TMetadata, TConstruction>, parser: Parser<TContext>): SelectorValidation<TContext, TMetadata, TConstruction>
}

Imagine the pain of implementing this interface inside a class.

@Kyasaki
Copy link

Kyasaki commented Jul 23, 2021

Actually, the following are different versions of the same code, implementing the previous interface; see by yourself.

Plain, painfull implementation

export class ChainSelector<TContext, TSelectorMap extends SelectorMap<TContext>>
implements Selector<TContext, ChainSelectorMetadata<TSelectorMap>, ChainSelectorConstruction<TSelectorMap>> {
    public constructor(chain: TSelectorMap) {
        throw Error('unimplemented')
    }

    public validate(parser: Parser<TContext>): SelectorValidation<TContext, ChainSelectorMetadata<TSelectorMap>, ChainSelectorConstruction<TSelectorMap>> {
        throw Error('unimplemented')
    }

    public revalidate(validation: PartialSelectorValidation<TContext, ChainSelectorMetadata<TSelectorMap>, ChainSelectorConstruction<TSelectorMap>>,
        parser: Parser<TContext>): SelectorValidation<TContext, ChainSelectorMetadata<TSelectorMap>, ChainSelectorConstruction<TSelectorMap>> {
        throw Error('unimplemented')
    }

    public construct(validation: CompleteSelectorValidation<TContext, ChainSelectorMetadata<TSelectorMap>, ChainSelectorConstruction<TSelectorMap>>,
        parser: Parser<TContext>): ChainSelectorConstruction<TSelectorMap> {
        throw Error('unimplemented')
    }
}

Implementation using template assignation

This is what leverages a bit the pain today, but please note the constraint repetitions in the form TAlias extends Pain<A, B, C> = Pain<A, B, C>.

export class ChainSelector<TContext, TSelectorMap extends SelectorMap<TContext>,
    TMetadata extends ChainSelectorMetadata<TSelectorMap> = ChainSelectorMetadata<TSelectorMap>,
    TConstruction extends ChainSelectorConstruction<TSelectorMap> = ChainSelectorConstruction<TSelectorMap>,
    TSelectorValidation extends SelectorValidation<TContext, TMetadata, TConstruction> = SelectorValidation<TContext, TMetadata, TConstruction>,
    TPartialSelectorValidation extends PartialSelectorValidation<TContext, TMetadata, TConstruction> = PartialSelectorValidation<TContext, TMetadata, TConstruction>,
    TCompleteSelectorValidation extends CompleteSelectorValidation<TContext, TMetadata, TConstruction> = CompleteSelectorValidation<TContext, TMetadata, TConstruction>,
>
implements Selector<TContext, TMetadata, TConstruction> {
    public constructor(chain: TSelectorMap) {
        throw Error('unimplemented')
    }

    public validate(parser: Parser<TContext>): TSelectorValidation {
        throw Error('unimplemented')
    }

    public revalidate(validation: TPartialSelectorValidation, parser: Parser<TContext>): TSelectorValidation {
        throw Error('unimplemented')
    }

    public construct(validation: TCompleteSelectorValidation, parser: Parser<TContext>): TConstruction {
        throw Error('unimplemented')
    }
}

Implementation using voodoo type aliases in template list

This is what I think it could look like with type aliases:

export class ChainSelector<TContext, TSelectorMap extends SelectorMap<TContext>,
    type TMetadata = ChainSelectorMetadata<TSelectorMap>,
    type TConstruction = ChainSelectorConstruction<TSelectorMap>,
    type TSelectorValidation = SelectorValidation<TContext, TMetadata, TConstruction>,
    type TPartialSelectorValidation = PartialSelectorValidation<TContext, TMetadata, TConstruction>,
    type TCompleteSelectorValidation = CompleteSelectorValidation<TContext, TMetadata, TConstruction>,
>
implements Selector<TContext, TMetadata, TConstruction> {
    public constructor(chain: TSelectorMap) {
        throw Error('unimplemented')
    }

    public validate(parser: Parser<TContext>): TSelectorValidation {
        throw Error('unimplemented')
    }

    public revalidate(validation: TPartialSelectorValidation, parser: Parser<TContext>): TSelectorValidation {
        throw Error('unimplemented')
    }

    public construct(validation: TCompleteSelectorValidation, parser: Parser<TContext>): TConstruction {
        throw Error('unimplemented')
    }
}

My first thought about this feature was to allow type and interfaces to be declared inside class and interfaces as within functions, but experimenting I found that the voodoo type alias syntax is much more powerfull and has further advantages:

  • Template lists greatfully apply to all type, interface, class and function. This means less pain updating TypeScript as to support this feature (I suppose).
  • The further implement or extends declarations profits of the entity aliases where in-entity type aliases would not.
  • It's close to what exists today, but just shorthands the need for the painfull TAlias extends Pain<A, B, C> = Pain<A, B, C>.
  • Type aliases would be scoped within the templated entity. Externally, type TAlias = Pain<A, B, C> would resolve to Pain<A, B, C> which seems perfectly fine.
  • Type aliases could be kept out of the templating usage of the entity. Type aliases should not be overriden, so why should TypeScript permit to provide them anyway?

Consider exporting type aliases

It would still be interesting to have the capability to export some of those aliases with something like public type TAlias = Pain<A, B, C>.

The reason for that is that the developer could have a lot less pain as to use them. If it's a pain writing the full type inside the class, chances are it's gonna be the same using it.
Therefore with exported aliases we could have something like this:

const chainSelector = new ChainSelector<MyContext, MySelectorMap>(someArbitraryChain)

// With exported type aliases, kewl
let chainValidation: (typeof chainSelector).TChainValidation
 
// Without exported type aliases, ewwww
let painfullChainValidation: SelectorValidation<TContext, ChainSelectorMetadata<TSelectorMap>, ChainSelectorConstruction<TSelectorMap>>

Side note, about templated modules

The reason I have so much template arguments inside my code is that I'm writing a generic parser. While in OCAML I would have written it using a Functor (which is no more than a templated module) I have not been able to find a way to reproduce this powerful feature in TypeScript, which I have no doubt would have reduced the list of template arguments furthermore.

@kristiandupont
Copy link

My workaround, which might be similar to what you are doing, @Kyasaki :

class Inner<
  T, 
  SomeDerivedType = Record<string, T>
> {
  doSomething(p: SomeDerivedType) {
    console.log(p)
  }
}

export class PublishedClass<T> extends Inner<T> {};

So, the SomeDerivedType is what I would ideally like to declare using type inside the class. I do it here in the generic parameters instead. However, since I don't want it exposed in my API, I create a new class that only takes one generic parameter and relies on the defaultness of the second.

@Kyasaki
Copy link

Kyasaki commented Jul 23, 2021

Rationale

After further tries, I found that the template assignation workaround cannot cover all cases. I'm currently fighting with several error codes which ruin any effort to emulate type aliases for classes, as templates arguments are not the exact alias type, but rather extend it. All the following samples will run fine, but whatever I tried, TypeScript is crying and me too:

Context

Be SelectorValidation a discriminated union type defined as so:

export type SelectorValidation<TContext, TMetadata, TConstruction> =
	| InvalidSelectorValidation
	| PartialSelectorValidation<TContext, TMetadata, TConstruction>
	| CompleteSelectorValidation<TContext, TMetadata, TConstruction>

export enum SelectorValidationKind {
	invalid,
	partial,
	complete,
}

export interface InvalidSelectorValidation {
	kind: SelectorValidationKind.invalid
}

export interface PartialSelectorValidation<TContext, TMetadata, TConstruction> {
	kind: SelectorValidationKind.partial
	selector: Selector<TContext, TMetadata, TConstruction>
	selection: Selection
	metadata: TMetadata
}

export interface CompleteSelectorValidation<TContext, TMetadata, TConstruction> {
	kind: SelectorValidationKind.complete
	selector: Selector<TContext, TMetadata, TConstruction>
	selection: Selection
	metadata: TMetadata
}

And validate a method defined as so:

private validate(parser: Parser<TContext>, validations: TSelectorValidation[]): void

Infer type: FAIL

const selectorValidations = this.selectors.map(selector => selector.validate(parser))
return this.validate(parser, selectorValidations) // TS2345

Argument of type 'SelectorValidation<TContext, unknown, unknown>[]' is not assignable to parameter of type 'TSelectorValidation[]'.
Type 'SelectorValidation<TContext, unknown, unknown>' is not assignable to type 'TSelectorValidation'.
'SelectorValidation<TContext, unknown, unknown>' is assignable to the constraint of type 'TSelectorValidation', but 'TSelectorValidation' could be instantiated with a different subtype of constraint 'SelectorValidation<TContext, unknown, unknown>'.
Type 'InvalidSelectorValidation' is not assignable to type 'TSelectorValidation'.
'InvalidSelectorValidation' is assignable to the constraint of type 'TSelectorValidation', but 'TSelectorValidation' could be instantiated with a different subtype of constraint 'SelectorValidation<TContext, unknown, unknown>'.ts(2345)

Constraint assignation to template alias: FAIL

const selectorValidations: TSelectorValidation = this.selectors.map(selector => selector.validate(parser)) // TS2332
return this.validate(parser, selectorValidations) // TS2345

Type 'SelectorValidation<TContext, unknown, unknown>[]' is not assignable to type 'TSelectorValidation'.
'TSelectorValidation' could be instantiated with an arbitrary type which could be unrelated to 'SelectorValidation<TContext, unknown, unknown>[]'.ts(2322)

Cast to template alias: FAIL

const selectorValidations = this.selectors.map(selector => selector.validate(parser)) as TSelectorValidation // TS2352
return this.validate(parser, selectorValidations) // TS2345

Conversion of type 'SelectorValidation<TContext, unknown, unknown>[]' to type 'TSelectorValidation' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
'TSelectorValidation' could be instantiated with an arbitrary type which could be unrelated to 'SelectorValidation<TContext, unknown, unknown>[]'.ts(2352)

Use the complete and two light years long type: SUCCESS

Using the complete type in the prototype, TypeScript won't complain anymore, but my developer fingers, eyes and heart are burning.

private validate(parser: Parser<TContext>, validations: TSelectorValidation<TContext, unknown, unknown>[])
const selectorValidations = this.selectors.map(selector => selector.validate(parser))
return this.validate(parser, selectorValidations) // Its ok now...

This proves further the true need for this feature. Nothing I know is able to fully implement working type aliases, and my code is 60% template names, VS 40% useful typescript. What do you think about those cases @weswigham ?

@jcalz jcalz mentioned this issue Aug 3, 2021
5 tasks
@jcalz jcalz mentioned this issue Oct 4, 2021
5 tasks
@kristiandupont
Copy link

Well, I am now running into a wall with my own workaround, as I want to create new generic types that are depending on the class type. But since higher kinded types are not supported, that is impossible. As it stands, it's impossible for me to extract my generic class into a library without some sort of code generation. Bummer.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus and removed Revisit An issue worth coming back to labels Feb 14, 2022
@shicks
Copy link
Contributor

shicks commented Aug 29, 2022

There are a handful of issues around allowing type parameters to be omitted (such as #26242 and #16597). This seems related by going a step further and requiring it to be omitted. Perhaps the solutions could also be related...?

Thinking of these as private generic template type parameters suggests an alternative syntax:

class Foo<TProvidedByUser, private TDerived = Complex<Expression<On<TProvidedByUser>>>> {
  // ...
}

The type checker could reason about it well enough to know that the default is necessarily a lower bound - so no extends ... clause should be necessary.

@nopeless
Copy link

There are a handful of issues around allowing type parameters to be omitted (such as #26242 and #16597). This seems related by going a step further and requiring it to be omitted. Perhaps the solutions could also be related...?

Thinking of these as private generic template type parameters suggests an alternative syntax:

class Foo<TProvidedByUser, private TDerived = Complex<Expression<On<TProvidedByUser>>>> {
  // ...
}

The type checker could reason about it well enough to know that the default is necessarily a lower bound - so no extends ... clause should be necessary.

@shicks the = is not the lower bound, but the default type supplied when there is no way to infer the data type or the user did not supply via arguments in <...>. So, extends keyword is necesary. TSC will complain about "can be instantiated with a different type unrelated to..." if you try the solution you posted.

I also think the double extends is redundant and can break existing code bases when attempting to introduce new generic type parameters to the class. I hope this issue gets the attention it deserves

@shicks
Copy link
Contributor

shicks commented Sep 23, 2022

@shicks the = is not the lower bound, but the default type supplied when there is no way to infer the data type or the user did not supply via arguments in <...>. So, extends keyword is necesary. TSC will complain about "can be instantiated with a different type unrelated to..." if you try the solution you posted.

As currently implemented, that's correct. My point was that the private specifier could tell the type checker that the user was not allowed to supply a different type in <...>, so it should be treated as an exact type. Since writing that I've learned that inference does still sometimes happen on types with a = (which I hadn't seen before, though it's still not as consistent as one might hope, else #26242 wouldn't be an issue), so maybe the syntax isn't quite correct here.

This came up again recently in js-temporal/temporal-polyfill/pull/183, where the author was trying to initialize a few complex derived types and wrote (slightly abbreviated for conciseness)

export function PrepareTemporalFields<
  FieldKeys extends AnyTemporalKey,
  OwnerT extends Owner<FieldKeys>,
  RequiredFieldKeys extends FieldKeys,
  RequiredFields extends readonly RequiredFieldKeys[] | FieldCompleteness,
  ReturnT extends (RequiredFields extends 'partial' ? Partial<OwnerT> : FieldObjectFromOwners<OwnerT, FieldKeys>
)>(
  bag: Partial<Record<FieldKeys, unknown>>,
  fields: readonly FieldKeys[],
  requiredFields: RequiredFields
): ReturnT {
  // ... details elided ...
  return result as unknown as ReturnT;
}

In this case, the intended usage is that none of the parameters are explicitly provided. As written, though, ReturnT is inferred rather than a simple direct alias. This means that if you assign the result of this function to a variable with any type that's a subtype of the actual return type (i.e. CorrectReturnType & SomeRandomInterface) then the type checker will happily back-infer this type and not complain, despite the fact that there's no way the function's implementation can possibly know about your interface to satisfy it (we call these "return-only generics" at Google and have banned them because they're inherently unsafe). Using an initializer doesn't help here, either, because it will still back-infer. The only solution I found that was completely safe against this issue was to wrap the type in the <...> and the unwrap it in the return, thus preventing the back-inference (playground):

declare const internal: unique symbol;
type Wrap<T> = {[internal](arg: T): T};
type Unwrap<W extends Wrap<any>> = W extends Wrap<infer T> ? T : never;
type EnsureWrapped<U extends Wrap<any>, T> = Wrap<any> extends U ? T : never;

declare function safer<
    T,
    TReturn extends Wrap<any> = Wrap<{foo: T}>
    >(arg: EnsureWrapped<TReturn, T>): Unwrap<TReturn>;

It would be nice not to have to do this, but instead just to declare TReturn as an internal alias.

@nopeless
Copy link

@shicks the = is not the lower bound, but the default type supplied when there is no way to infer the data type or the user did not supply via arguments in <...>. So, extends keyword is necesary. TSC will complain about "can be instantiated with a different type unrelated to..." if you try the solution you posted.

As currently implemented, that's correct. My point was that the private specifier could tell the type checker that the user was not allowed to supply a different type in <...>, so it should be treated as an exact type. Since writing that I've learned that inference does still sometimes happen on types with a = (which I hadn't seen before, though it's still not as consistent as one might hope, else #26242 wouldn't be an issue), so maybe the syntax isn't quite correct here.

This came up again recently in js-temporal/temporal-polyfill/pull/183, where the author was trying to initialize a few complex derived types and wrote (slightly abbreviated for conciseness)

export function PrepareTemporalFields<
  FieldKeys extends AnyTemporalKey,
  OwnerT extends Owner<FieldKeys>,
  RequiredFieldKeys extends FieldKeys,
  RequiredFields extends readonly RequiredFieldKeys[] | FieldCompleteness,
  ReturnT extends (RequiredFields extends 'partial' ? Partial<OwnerT> : FieldObjectFromOwners<OwnerT, FieldKeys>
)>(
  bag: Partial<Record<FieldKeys, unknown>>,
  fields: readonly FieldKeys[],
  requiredFields: RequiredFields
): ReturnT {
  // ... details elided ...
  return result as unknown as ReturnT;
}

In this case, the intended usage is that none of the parameters are explicitly provided. As written, though, ReturnT is inferred rather than a simple direct alias. This means that if you assign the result of this function to a variable with any type that's a subtype of the actual return type (i.e. CorrectReturnType & SomeRandomInterface) then the type checker will happily back-infer this type and not complain, despite the fact that there's no way the function's implementation can possibly know about your interface to satisfy it (we call these "return-only generics" at Google and have banned them because they're inherently unsafe). Using an initializer doesn't help here, either, because it will still back-infer. The only solution I found that was completely safe against this issue was to wrap the type in the <...> and the unwrap it in the return, thus preventing the back-inference (playground):

declare const internal: unique symbol;
type Wrap<T> = {[internal](arg: T): T};
type Unwrap<W extends Wrap<any>> = W extends Wrap<infer T> ? T : never;
type EnsureWrapped<U extends Wrap<any>, T> = Wrap<any> extends U ? T : never;

declare function safer<
    T,
    TReturn extends Wrap<any> = Wrap<{foo: T}>
    >(arg: EnsureWrapped<TReturn, T>): Unwrap<TReturn>;

It would be nice not to have to do this, but instead just to declare TReturn as an internal alias.

Thanks for explaining
Sorry for misunderstanding what you originally said. I played around with the unwrapped type inference you had and its a little similar to what I had

Basically I had two generic type parameters that were somehow linked to each other and I had to verify. I did that by using operators in the constructor. Your code makes it a little more obvious that the default parameter is indeed the intended one. Cheers

@shicks
Copy link
Contributor

shicks commented Jan 30, 2023

I consider this part of a handful of related issues needed for library-friendly type checking.

I recently put together a "wish list" (see this gist) for a few features that I'd like to see in TypeScript generics, and that have some pretty solid synergy (such that any one by itself may not be particularly compelling, but when combined with the others, it makes some significant improvements).

These are clearly relevant issues that a lot of people would like to see addressed. It would be great if we could get some more eyes on them, particularly from the TypeScript maintainers.

@streamich
Copy link

streamich commented Apr 7, 2023

This would enable the following pattern.

Instead of:

class A {
  method(a) {
    type Result = a extends ... infer ...
    return result as Result;
  }
}

one could do:

class A {
  type Result<A> = A extends ... infer ...
  method<A>(a: A): Result<A> {
    return result;
  }
}

now imagine that type is used in multiple methods (yes, I know it can be pulled out of the class, but sometimes it is nicer to have those types next to the class methods where it is used):

class A {
  type Result<A> = A extends ... infer ...

  method1<A>(a: A): Result<A> {
    return result;
  }

  method2<A>(a: A): Result<A> {
    return result;
  }

  method3<A>(a: A): Result<A> {
    return result;
  }
}

@streamich
Copy link

streamich commented Apr 7, 2023

Here is an excerpt from real code:

image

Note, the types depend on Methods class generic, so the types cannot be easily extracted outside of the class, that Methods would need to be passed as a param.

@streamich
Copy link

streamich commented Apr 7, 2023

Basically, instead of this:

class A<Methods> {
  public fn<K extends keyof Methods>(method: K) {
    type Res = Methods[K] extends WorkerMethod<any, infer R> ? R : never;
    type Chan = Methods[K] extends WorkerCh<any, infer I, infer O, any> ? [I, O] : never;
    return this.module.fn<Res, Chan[0], Chan[1]>(method as string);
  }
}

I would like to be able to write:

class A<Methods> {
  public fn<K extends keyof Methods>(method: K) {
    return this.module.fn<Res<K>, In<K>, Out<K>>(method as string);
  }

  type Res<K extends keyof Methods> = Methods[K] extends WorkerMethod<any, infer R> ? R : never;
  type In<K extends keyof Methods> = Methods[K] extends WorkerCh<any, infer I, infer O, any> ? I : never;
  type Out<K extends keyof Methods> = Methods[K] extends WorkerCh<any, infer I, infer O, any> ? O : never;
}

image

@martaver
Copy link

It's hard to imagine why allowing type aliases in the scope of a class would be such an impactful change. It would enable a clearer, and more expressive separation of type-logic from runtime logic, bringing it inline with what's possible in functions and closures.

I think this is in line with Typescript's design guideline:

  1. Produce a language that is composable and easy to reason about.

@xiBread xiBread mentioned this issue Nov 4, 2023
6 tasks
@ibraheemhlaiyil
Copy link

+1 for more readable code with this feature

@Pyrdacor
Copy link

Pyrdacor commented Jul 29, 2024

I found a small "hack" to do this:

class FooHelper<T extends { id: IndexableType }, Id = T['id']> {
    public getById(id: Id): T {
        return { id: 0 } as T;
    }
}

export class Foo<T extends { id: IndexableType }> extends FooHelper<T> {}

So basically you define the types as additional generic parameters of the class and use another class to ensure, that those generic parameters keep their default value. So inside FooHelper you can add all the implementation and can use the type Id for more readability. But you only export Foo so the user can't change the type through type arguments.

Of course this is just a hacky workaround and I also want to be able to define type aliases in classes.

P.S.: If it is safe enough for your use case you can just use this:

export class Foo<T extends { id: IndexableType }, Id extends T['id'] = T['id']> {
    public getById(id: Id): T {
        return { id: 0 } as T;
    }
}

The user could now change the type of Id but it is ensured that it at least extends T['id'].

The first approach hides this type parameter completely from the user of the class.

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