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

Support final classes (non-subclassable) #8306

Closed
Zorgatone opened this issue Apr 26, 2016 · 156 comments
Closed

Support final classes (non-subclassable) #8306

Zorgatone opened this issue Apr 26, 2016 · 156 comments
Labels
Suggestion An idea for TypeScript Won't Fix The severity and priority of this issue do not warrant the time or complexity needed to fix it

Comments

@Zorgatone
Copy link

I was thinking it could be useful to have a way to specify that a class should not be subclassed, so that the compiler would warn the user on compilation if it sees another class extending the original one.

On Java a class marked with final cannot be extended, so with the same keyword on TypeScript it would look like this:

final class foo {
    constructor() {
    }
}

class bar extends foo { // Error: foo is final and cannot be extended
    constructor() {
        super();
    }
}
@mhegazy
Copy link
Contributor

mhegazy commented Apr 26, 2016

a class with private constructor is not extendable. consider using this instead.

@mhegazy mhegazy added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Apr 26, 2016
@Zorgatone
Copy link
Author

From what I recalled I was sure the compiler didn't like the private keyword on the constructor. Maybe I'm not using the paste version though

@mhegazy
Copy link
Contributor

mhegazy commented Apr 26, 2016

This is a new feature, will be released in TS 2.0, but you can try it using typescript@next. see #6885 for more details.

@Zorgatone
Copy link
Author

Ok thank you

@duanyao
Copy link

duanyao commented Apr 27, 2016

Doesn't private constructor also make a class not instantiatable out of the class? It's not a right answer to final class.

@mhegazy
Copy link
Contributor

mhegazy commented May 17, 2016

Java and/or C# uses the final class to optimize your class at runtime, knowing that it is not going to be specialized. this i would argue is the main value for final support. In TypeScript there is nothing we can offer to make your code run any better than it did without final.
Consider using comments to inform your users of the correct use of the class, and/or not exposing the classes you intend to be final, and expose their interfaces instead.

@mhegazy mhegazy closed this as completed May 17, 2016
@mhegazy mhegazy added Won't Fix The severity and priority of this issue do not warrant the time or complexity needed to fix it and removed In Discussion Not yet reached consensus labels May 17, 2016
@0815fox
Copy link

0815fox commented Jun 20, 2016

I do not agree with that, instead I agree with duanyao. Private does not solve that issue, because I also want classes which are final to be instanciateable using a constructor. Also not exposing them to the user would force me to write additional factories for them. For me the main value of final support is, that it prevents users from making mistakes.
Arguing like that: What does TypeScript offer to make my code run faster, when I use types in function signatures? Isn't it also only for preventing users from making mistakes? I could write comments describing which types of values a user should pass in as a parameter. It's a pitty, that such extensions like a final keyword are just pushed away, because on my opinion it collides with the original intension of typescript: make JavaScript safer by adding a compilation level, which performs as many checks as possible to avoid as many mistakes upfront as possible. Or did I misunderstand the intention of TypeScript?

@0815fox
Copy link

0815fox commented Jun 20, 2016

There should also be a final modifier for methods:

class Foo {
  final fooIt():void{

  }
}

class Bar {
  fooIt():void {

  }
}
// => Method fooIt of Bar cannot override fooIt of Foo, because it is final.

E.g. I often use following pattern, where I want to urgently avoid fooIt to be overridden:

import Whatever ...

abstract class Foo {
  private ImportantVariable:boolean;

  protected abstract fooIt_inner:Whatever();

  public final fooIt():Whatever() {
    //do somestate change to aprivate member here, which is very crucial for the functionality of every Foo:
    ImportantVariable = true;
    //call the abstract inner functionality:
    return this.fooIt_inner();    
  }
}

@mhegazy
Copy link
Contributor

mhegazy commented Jun 20, 2016

The argument about cost vs. utility is a fairly subjective one. The main concern is every new feature, construct, or keyword adds complexity to the language and the compiler/tools implementation. What we try to do in the language design meetings is to understand the trade offs, and only add new features when the added value out weights the complexity introduced.

The issue is not locked to allow members of the community to continue adding feedback. With enough feedback and compelling use cases, issues can be reopened.

@mindarelus
Copy link

mindarelus commented Aug 7, 2016

Actually final is very simple concept, does not add any complexity to the language and it should be added. At least for methods. It adds value, when a lot of people work on a big project, it is valuable not to allow someone to override methods, that shouldn't be overridden.

@mcdirmid
Copy link

mcdirmid commented Sep 9, 2016

In TypeScript there is nothing we can offer to make your code run any better than it did without final.

Wow, cringe! Static types don't make your code run any better either, but safety is a nice thing to have.

Final (sealed) is right up there with override as features I'd like to see to make class customizations a bit safer. I don't care about performance.

@pauldraper
Copy link

pauldraper commented Oct 10, 2016

Static types don't make your code run any better either, but safety is a nice thing to have.

Exactly. Just as private prvents others from calling the method, final limits others from overriding the method.

Both are part of the class's OO interface with the outside world.

@timmeeuwissen
Copy link

Completely agree with @pauldraper and @mindarelus. Please implement this, this would make a lot of sense I really miss it currently.

@aluanhaddad
Copy link
Contributor

I don't think final is only beneficial for performance, it's also beneficial for design but I don't think it makes sense in TypeScript at all. I think this is better solved by tracking the mutability effects of Object.freeze and Object.seal.

@0815fox
Copy link

0815fox commented Oct 24, 2016

@aluanhaddad Can you explain that in more detail? Why do you think it does not "make sense in TypeScript at all"?
Freezing or sealing object means to disallow adding new properties to an object, but does not prevent adding properties to a derived object, so even if I would seal the base class I could still override the method in a child class, which extends that base class. Plus I could not add any properties to the base class at runtime.

@hk0i
Copy link

hk0i commented Oct 29, 2016

The idea of using final on a class or class method in java has more to do with minimizing mutability of the object for thread safety in my opinion. (Item 15. Joshua Bloch, Effective Java)

I don't know if these principals carry over into javascript seeing as everything in JS is mutable (correct me if I'm wrong). But Typescript is not Javascript, yeah?

I would really like to see this implemented. I think it'll help create more robust code. Now... How that translates into JS, it honestly probably doesn't have to. It can just stay on the typescript side of the fence where the rest of our compile-time checking is.

Sure I can live without it, but that's part of what typescript is, right? Double checking our overlooked mistakes?

@rylphs
Copy link

rylphs commented Nov 1, 2016

To me final would play the same role in typescript as private or typings, that is code contract. They can be used to ensure your code contract don't get broken. I would like it so much.

@cloverich
Copy link

@hk0i its also mentioned in Item 17 (2nd edition) in a manner similar to what's been echoed here:

But what about ordinary concrete classes? Traditionally, they are neither final nor designed and documented for subclassing, but this state of affairs is danger- ous. Each time a change is made in such a class, there is a chance that client classes that extend the class will break. This is not just a theoretical problem. It is not uncommon to receive subclassing-related bug reports after modifying the internals of a nonfinal concrete class that was not designed and documented for inheritance.

The best solution to this problem is to prohibit subclassing in classes that are not designed and documented to be safely subclassed. There are two ways to prohibit subclassing. The easier of the two is to declare the class final. The alternative is to make all the constructors private or package-private and to add public static factories in place of the constructors.

I would argue it does not increase the cognitive complexity of the language given that the abstract keyword already exists. However, I cannot speak to the implementation / performance impact of it and absolutely respect protecting the language from that angle. I think separating those concerns would be fruitful towards deciding whether or not to implement this feature.

@MrDesjardins
Copy link

MrDesjardins commented Mar 30, 2017

I believe that final would be an excellent addition to seal a class. One use case is that you may have a lot of public methods in your class but expose, through an interface, just a subset of them. You can unit tests the implementation quickly since it has all these public methods while the real consumer uses the interface to limits access to them. Being able to seal the implementation would ensure that no one extends the implementation or change public methods.

You may also ensure that no one is inheriting your class. TypeScript should be there to enforce those rules, and the suggestion about commenting seems to be a lazy approach to solve this use case. The other answer I read is about using private which is only suitable for a particular situation but not the one I explained above.

Like many people in this thread, I would vote to be able to seal class.

@kurkowski
Copy link

@binki, yeah, completely agree. I'm all for compile-time checking over runtime. I was just trying to see how something could be done during runtime because it wasn't clear to me.

@dgreensp
Copy link

Now that the override keyword is slated for 4.3 — because it is useful to have discipline about overrides in large codebases, as the Google team attests — I think some of the arguments against being able to mark a method as final (meaning non-overridable) are invalid.

Here are some of the reasons for rejecting final methods given by (I believe) members of the TS team:

(1) Final (non-overridable) methods are basically the same language feature as final (non-extendable) classes. They should be discussed together. Reasons to implement or not implement one apply to the other. The belief in this proposition is why I must post here, in this thread, and not in an issue dedicated to final methods. FWIW, they seem like totally different language features to me. In particular, I don't care about final classes. We might have to agree to disagree on this one.

(2) It's not useful, valuable, or in scope for TypeScript to get involved with the contract between superclasses and subclasses. The usefulness and suitability of override (which asserts that a method is an override) invalidates this, not to mention the fact that we have keywords like protected providing similar OO contract enforcement. There is not that much semantic category difference between "this method must override something" and "nothing may override this method."

(3) The purpose of the final keyword in languages like Java is basically performance. No, the primary purpose is for correctness by enforcing a contract. Some methods are meant to be overridden by subclasses, and some are not, just as a public member is meant to be accessed outside a class, while a private member is not.

(4) This feature is in scope for ECMAScript, so we can't just do our own thing. This actually sounds valid, if we're really talking about final methods in particular, and if there is a potential for divergence of behavior (as with decorators). (So override is not in scope of ECMAScript, but final for methods is?) It's true that final has runtime implications and guarantees in Java, and perhaps it could in JavaScript, like the way private fields are actually enforced by the engine, though strong guarantees about class inheritance don't seem that useful for JavaScript performance or security, I mean aren't classes basically a sort of built-in syntactic sugar for prototypes anyway? Blocking overriding at the prototype chain level would be as different a feature from what we're talking about here as private fields vs. TypeScript's private, in terms of the mechanics of it. Or, ECMAScript's final methods could just end up being the same as TypeScript's, plus runtime enforcement. How much room for divergence of behavior is there really?

The only logical reason for not implementing final methods that I can come up with is (4), namely that ECMAScript could introduce a final keyword on methods of its own, that is incompatible with TypeScript's.

Obviously, no amount of logic forces the wonderful TypeScript team to go and immediately implement a feature like final methods. They are busy and can do whatever they want. I just wanted to lay out the argument and maybe clear out some distracting cobwebs from the conversation.

@johncrim
Copy link

johncrim commented Mar 7, 2021

I'm a big fan of final/sealed in every language and the stronger contract it allows, but I think the existing support is nearly sufficient - the example in TypeScript handbook | Class Decorators:

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed
export class CantSubclassMe {
  ...
}

The only downside I see to this is that it doesn't provide compile-time enforcement, but it does provide run-time enforcement.

**** EDIT **** (180 degree change)

Oops, I made the mistake of thinking this would work as it reads without testing it and reading the fine print/thinking about it. Turns out the @sealed decorator described in the handbook doesn't do much. If you freeze the prototype, you can prevent prototype modification, but due to prototype chaining sealing a constructor or prototype doesn't do much.

export function sealed(constructor: Function) {
  Object.seal(constructor); // Doesn't seem to do much - not sure why it's included
  Object.freeze(constructor.prototype); // Object.seal doesn't protect much, but Object.freeze does
}

@sealed
class A {
  method() {
    console.log('A.method()');
  }
}
class B extends A { }
class C extends A {
  method() {
    console.log('C.method()');
  }
}

/** Same as class A, but prototype is modified in test */
@sealed
class AA {
  method() {
    console.log('AA.method()');
  }
}

describe('@sealed', () => {

  it('does not prevent subclassing a class', () => {
    const b = new B();
    expect(b).toBeInstanceOf(B);
  });

  it('does not prevent overriding methods in subclasses', () => {
    const c = new C();
    expect(c).toBeInstanceOf(C);
  });

  it('does prevent replacing methods in the same prototype', () => {
    expect(() => {
      AA.prototype.method = () => { console.log('prototype replaced AA.method()'); };
    }).toThrowMatching(e => e instanceof TypeError);
  });

  it('does prevent replacing methods in a constructed object', () => {
    const a = new A();
    expect(a).toBeInstanceOf(A);

    expect(() => {
      a.method = () => { console.log('instance replaced A.method()'); };
    }).toThrowMatching(e => e instanceof TypeError);
  });

});

So, it turns out that existing language features DO NOT handle this case.

@johncrim
Copy link

johncrim commented Mar 8, 2021

I put a little more work into this decorator to make it work. It does provide runtime enforcement that a subclassed @final class cannot be instantiated. It does not provide compile time checks or method level enforcement.

This gist provides more complete code (tests, etc), but here's the key part:

export declare interface Type<T> extends Function {
    new (...args: any[]): T;
}

function preventSubclassing<T>(constructor: Type<T>): Type<T> {
  const newCtor = function (...args: any[]): T {
    if (new.target.prototype !== constructor.prototype) {
      throw new TypeError(`${constructor.name} cannot be subclassed`);
    }
    return new constructor(args);
  };

  // copy prototype so instanceof operator still works
  newCtor.prototype = constructor.prototype;
  return newCtor as unknown as Type<T>;
}

/**
 * Class decorator that prevents type modification and extension. If the decorated
 * class is subclassed, a `TypeError` is thrown when the subclass is constructed.
 *
 * The name `@final` is used instead of `@sealed` b/c in Javascript, sealing
 * protects against object modification (not type extension).
 */
export function final<T>(constructor: Type<T>): Type<T> {
  const newCtor = preventSubclassing(constructor);
  Object.seal(newCtor);
  Object.freeze(newCtor.prototype);
  return newCtor;
}

Usage:

@final
class A {}

class B extends A { }

const b = new B(); // throws TypeError('A cannot be subclassed');

I'm not certain that it's production ready yet - consider it a concept. For example, right now there are 2 calls to new when a decorated class is instantiated, and I'm not sure if that's a problem or not (does it result in a wasted allocation?).

I would still love to see first-class typescript support for a final keyword that seals classes or methods, but it would be ideal to have support for both compile and runtime checks.

@pihart
Copy link

pihart commented Mar 31, 2021

Notably absent from this discussion is that final classes and final methods (independently) allow TS to analyze assignments in methods called in the constructor as if they were written directly in the constructor.

With strictPropertyInitialization: true, the following class declaration is an error (TS2564: Property 'someProperty' has no initializer and is not definitely assigned in the constructor.):

class SomeClass {
  someProperty: number;

  constructor() {
    this.setup();
  }

  // or init()
  setup() {
    this.someProperty = 0;
  }
}

despite the fact that the following compiles without error:

class SomeClass {
  someProperty: number;

  constructor() {
    this.someProperty = 0;
  }
}

The reason is that TS doesn't know whether any subclass of SomeClass redefines setup(), and thereby removes the assignments.

The workarounds, which don't provide assurance that setup() won't be overridden, are:

  1. assert someProperty as non-null (unsafe),
  2. add undefined to the type of someProperty/make someProperty optional (clunky, propagating, and extraordinarily weak)
  3. copy over all assignments made in setup() to the constructor or the declarations (messy).

I'm sure most of us see all of these as both bad practice and difficult to maintain.

Adding the final keyword to either SomeClass or setup(), though, would clearly convey intent, and provide the necessary protection. As a result, the type system would have sufficient information to deduce that someProperty is actually defined in the constructor.


I suppose many developers overlook that methods called in the constructor can be overridden, and rely on behavior defined in the "base class." In particular, in the above example, setup() could perform logic that's critical to the initialization of the object. It could instead be reset(), a method to roughly restore the class to its initial state. In either case, the method's exact behavior could be used as an assumption unknowingly in the constructor.

@martin19
Copy link

martin19 commented Feb 5, 2022

@mhegazy There is no question this keyword makes sense and is useful both on class and methdo level - and I do not talk about runtime optimization here.
The suggestion to write comments to inform the user of a class is just ridiculous - I just need this keyword to restrict user of my class (myself) doing dumb things with it like overriding a method not meant to be overridden.
The keyword does not introduce any complexity for the end user of the language. It is a strictly optional check for the compiler to do when overriding a method. Emit a warning/error - thats all.

I suggest you put this on the roadmap along with a bunch of other heavily upvoted features.

@flash42
Copy link

flash42 commented Feb 8, 2022

How you see the problem (based on replies)
From the answers of the contributors I can decipher what you think on the benefits of the final keyword:

  • prevent extending a class (not methods)
  • optimize class
  • constraining mutability

Another aspect: OOP design
As others have mentioned the use of final keyword especially on methods is a necessary tool to build OOP programs.

I want to cite an chapter title from Joshua Bloch's Effective Java:

"Design and document for inheritance or else prohibit it"

This chapter builds on the well known open-closed principle which is supported by TypeScript. If you write a class and you wan't your users to build on it you allow them to extend the class. Why? Because then it will be useful in countless ways!
But if you can't protect your base class's invariants you open up the way for countless mistakes to be made as well.

You want to prohibit extending the class as in actuality inheritance can break invariants of your base class. This is not a theoretical issue.

Real-world example: Angular
I came here because I want to build an Angular component with an abstract class, with the the abstract methods being the only extensions points. As my base class implements Angular life-cycle hooks, which of course can't be private, I want to prevent children components to mistakenly disable the functionality of my abstract base class. If they override these life-cycle hooks my component won't be able to provide its functionality at all!

@kberg
Copy link

kberg commented Aug 17, 2022

Hi. I respect the comment in #9264 (comment) that this request isn't going to be relitigated. I scanned, but did not read all the cases, so apologies if this use case has already been described.

I have this set-up:

interface Interface {
  canFoo(): boolean;
}

class Base implements Interface {
  public canFoo() { return true; }
}

class Instance extends Base {
  public canFoo() { conditions...; }
}

There are somewhere between 300-400 subclasses of Base, many of them have their own canFoo().

There is also a controller of sorts which does a lot of the things that should be in a common canFoo type situation. So 90% of the reasons an instance can Foo is because the controller is checking it separately. So for the sake of clarity, I'd like to move all of that onto Base.

interface Interface {
  canFoo(): boolean;
}

class Base implements Interface {
  public abstract moreCanFoo() {
    return true;
  }

  public final canFoo() {
    if (!common-conditions) return false;
    return this.moreCanFoo();
  }
}

class Instance extends Base {
  public moreCanFoo() { conditions...; }
}

If Base.canFoo is not final, I can accidentally forget to rename the Instances' canFoos, creating new issues.

Edited to add I acknowledge this is solvable other ways, like renaming all the canFoos first. But I like when the compiler works for me. It brings me confidence.

@infacto
Copy link

infacto commented Aug 26, 2022

Consider to reopen. Prevent methods from override with the final or sealed (or existing readonly?) keyword. It's weird that this issue has been closed as it has no positive impact on runtime. See post / close reason. It's TypeScript. It's not about runtime. It's about hints to prevent a class method from override. Either the whole class or single method. Runtime does not matter. Like the most stuff in TypeScript. Like private or override. It's also about type definition files for e.g. APIs.

class MyService {
  public getConfig() {
     // Override me.
  }

  public start() {
    const config = this.getConfig();
    // Get config and do stuff. But don't override me. Needs a keyword to prevent from overriding.
  }
}

https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/sealed

@mahdi-farnia
Copy link

Java and/or C# uses the final class to optimize your class at runtime, knowing that it is not going to be specialized. this i would argue is the main value for final support. In TypeScript there is nothing we can offer to make your code run any better than it did without final. Consider using comments to inform your users of the correct use of the class, and/or not exposing the classes you intend to be final, and expose their interfaces instead.

What does typescript exactly do in runtime for optimizing??????

All about preventing bugs.

@cbutterfield
Copy link

cbutterfield commented Sep 9, 2022 via email

@kayahr
Copy link

kayahr commented Sep 10, 2022

Instead of riding this dead horse switch over to the well written retry of this whole issue in #50532, give it your whole support at least with thumbs-up reactions and hope this issue is not blindly closed for the wrong reasons again.

@binki
Copy link

binki commented Sep 10, 2022

@mahdi-farnia

What does typescript exactly do in runtime for optimizing??????

Initializing members of classes consistently reduces the number of “shapes”, preventing some deoptimization/bailout scenarios. While this is not the primary goal of TypeScript, it does make sense for TypeScript to avoid features which can not be translated into efficient JavaScript equivalents. For example, I would not expect TypeScript to add a concise syntax for an operator which implements a deep equality check that would require full object traversal. That belongs in a library and adding an operator would make the operation appear lightweight to the developer while being heavyweight.

@cbutterfield

Sadly, over a number of years, the powers that be constantly "misinterpret" a request that is primary related to writing safe and reliable code as something to do with performance. It is quite bizarre.

I think the powers that be got offended early on in the process and now routinely reject anything with the word final in it (e.g. final methods, final classes) as an affront to their dignity.

I wouldn’t say this so strongly, but I do agree. TypeScript lead the way with JavaScript language innovation by introducing an implementation of async/await whose syntax was adopted into ECMAScript (even if nuances in behavior are not identical). It even started out with providing class before JavaScript had it. But TypeScript decided that they want to avoid creating features which could force them to deviate from future developments in ECMAScript. They have gone so far with this, that they are no longer willing to innovate or deviate from pure JavaScript behavior even for things which exist entirely in TypeScript’s realm such as nominal or final classes.

It’s understandable that they don’t want to risk making new syntax which conflicts with future changes to ECMAScript. But it comes at the cost of preventing people from expressing certain constraints which would be very beneficial in a static type checker. It is a balance, so a line needs to be drawn somewhere, but it seems to be drawn in a different place than it was when TypeScript was first created, unfortunately.

So… are there any good TypeScript forks? Or is the ecosystem forcing us to stay? Hrm…

@Max10240
Copy link

Max10240 commented Jul 15, 2023

Does this help? (At the method or attribute level)
TS playground link

declare const _: unique symbol;
type NoOverride = { [_]: typeof _; }

class A {
  readonly baz: string & NoOverride = '' as any;

  // Note - `ReturnType & NoOverride`
  foo(): { a: string } & NoOverride {
    return { a: '' } as any;
  }

  // if this function return nothing, use `NoOverride` only
  bar(): NoOverride {
    console.log(0);

    return null!;
  }
}

class B extends A {
  // @ts-expect-error - Type 'string' is not assignable to type 'NoOverride'.
  baz = '';

  // @ts-expect-error - Property '[_]' is missing in type '{ a: string; }' but required in type 'NoOverride'.
  foo() {
    return { a: '' };
  }

  // @ts-expect-error - Type 'void' is not assignable to type 'NoOverride'.
  bar() {
  }
}

@binki
Copy link

binki commented Jul 15, 2023

@Max10240 doesn't that break external module doing new A().baz = 'asdf';?

@Max10240
Copy link

@Max10240 doesn't that break external module doing new A().baz = 'asdf';?

Yes. The above answers only apply to the method or attribute level. If you want to make a property to be final , I think it must first be "readonly". I updated the comment to explicitly add the "readonly" modifier!

@pauldraper
Copy link

pauldraper commented Jul 25, 2023

TypeScript lead the way with JavaScript language innovation by introducing an implementation of async/await whose syntax was adopted into ECMAScript (even if nuances in behavior are not identical).

The ES proposal predated TypeScript implementation by over a year and a half.

It even started out with providing class before JavaScript had it.

Classes were included in ES4/Harmony, years before TS existed.


Better examples would be decorators or private members.

Especially private members; TS added them as a type-level feature, but then JS added private members as a language/runtime feature.

It's possible (but unlikely) that JS would do something similar with final classes.

All that said, TS supporting final classes + nominal tying would be very useful.

@Voltra
Copy link

Voltra commented Oct 22, 2023

Sometimes TypeScript design decisions surprise me. How can you both have override but not final? Every reason you have for override can be adapted for final/sealed.

Kinda the same vibes as "not generating anything at runtime" but also having had both the decorators and int-backed enums generate extra code.

@EduApps-CDG

This comment was marked as off-topic.

@Abion47
Copy link

Abion47 commented Feb 28, 2024

I ran into an issue stemming from the lack of sealed that I think introduces a use-case that hasn't yet been covered in this thread. (If it has, I apologize, but it's a very long thread.)

Some languages are coming out with the final/sealed feature but with a twist: the class can be inherited but only from the same file/module that the base class was defined. This extends the idea of an unmodifiable class and adds the idea of a closed enumeration of subclasses, which I think extends to TypeScript nicely as it allows subclasses to be defined in a way that mimics a type union.

Here's an incredibly simplified version of what I was trying to do:

//////////////////
// model.ts

export abstract class Base {
  static factory(): Base {
    // Some logic to return either a Foo or a Bar
  }

  abstract isFoo(): this is Foo;
  abstract isBar(): this is Bar;
}

export class Foo extends Base {
  constructor(public a: string) { super(); }

  override isFoo(): this is Foo { return true; }
  override isBar(): this is Bar { return false; }
}

export class Bar extends Base {
  constructor(public b: number) { super(); }

  override isFoo(): this is Foo { return false; }
  override isBar(): this is Bar { return true; }
}

//////////////////
// index.ts

const val = Base.factory();

if (val.isFoo()) {
  console.log(val.a); // Can access `a` because `val` is now Foo
}

What I'd like to do is be able to tell the compiler that if val isn't a Foo, it must be a Bar. This is something you can do with a type union, but not with subclasses.

if (val.isFoo()) {
  console.log(val.a); // Can access `a` because `val` is now Foo
} else {
  console.log(val.b); // Error: even though the only other subtype of `Base` that I defined was `Bar`, the compiler can't guarantee some other subtype of `Base` doesn't exist
}

If Base was marked as sealed, however, the compiler would be able to know that Foo and Bar are the only subtypes of Base that can exist and inform the type system accordingly:

//////////////////
// model.ts

export sealed abstract class Base {
  ...
}

export class Foo extends Base {
  ...
}

export class Bar extends Base {
  ...
}

...

//////////////////
// model2.ts

export class Baz extends Base { // Error: Baz cannot extend Base as Base is sealed.
 ...
}

//////////////////
// index.ts

const val = Base.factory();

if (val.isFoo()) {
  console.log(val.a); // Can access `a` because `val` is now Foo
} else {
  console.log(val.b); // Succeeds because the compiler knows that if `val` isn't `Foo`, it must be `Bar`
}

Is there a way to accomplish this with just using a type union? Sure.

//////////////////
// model-utility.ts

export type BaseType = Foo | Bar;

export function isFoo(obj: BaseType): obj is Foo {
  return obj instanceof Foo
}

export function isBar(obj: BaseType): object is Bar {
  return obj instanceof Bar;
}

//////////////////
// index.ts

const val = Base.factory() as BaseType;

if (isFoo(val)) {
  console.log(val.a);
} else {
  console.log(val.b);
}

But that approach has several issues for me:

  1. It's idiomatic of an FP-oriented coding style, which is a stark contrast to both my personal coding style and the general coding style of my team at work. (It is what it is.)
  2. It requires additional boilerplate to be written especially if the base types I'm working with come from a library I don't control.
  3. The isFoo and isBar utility functions are separated from the Base/Foo/Bar family of classes which hurts API discoverability.
  4. It's yet another thing I have to import which unnecessarily bloats the import section.

Interestingly, I can combine the two approaches:

//////////////////
// model.ts

export type BaseType = Foo | Bar;

export abstract class Base {
  static factory(): BaseType { // Return type is the type union instead of the abstract class
     // Some logic to return either a Foo or a Bar
  }

  abstract isFoo(): this is Foo;
  abstract isBar(): this is Bar;
}

export class Foo extends Base {
  constructor(public a: string) { super(); }

  override isFoo(): this is Foo { return true; }
  override isBar(): this is Bar { return false; }
}

export class Bar extends Base {
  constructor(public b: number) { super(); }

  override isFoo(): this is Foo { return false; }
  override isBar(): this is Bar { return true; }
}

//////////////////
// index.ts

const val = Base.factory(); // `val` is now of type `BaseType` (i.e. `Foo | Bar`) instead of `Base`

if (val.isFoo()) {
  console.log(val.a);
} else {
  console.log(val.b);
}

Syntactically, this method works, but it has its own issues:

  1. This change affects the entire API as everything that returns Base now has to return BaseType instead. Otherwise, the "solution" won't work.
  2. Conceptually, it just feels wrong that the type of val is now Foo | Bar instead of Base. If I'm using OOP, I would prefer to stay in the realm of OOP.

@Voltra
Copy link

Voltra commented Feb 28, 2024

final in the cases of C++ or Java is used for compile-time optimization sure, but it's also used to statically enforce a "contract" for library consumers: you cannot override (or extend for classes' case).

@EduApps-CDG
Copy link

but it's also used to statically enforce a "contract"
@Voltra we learn that in Software Engineering class.

I have a extensible database design (I make plug-ins for my own project) the system plug-ins shall not be extended, so they would be final.

@paulshryock
Copy link

It's bonkers that TypeScript still doesn't have final classes and methods.

@dgreensp
Copy link

dgreensp commented Apr 27, 2024

Every so often I think of this issue, and #33446 (final methods), and the 2016 and 2019 responses from the TypeScript team, and it brings me down, not so much for the rejection of the feature as for being inaccurate or illogical in the reasons given. I wrote a wordy comment in 2021, but I just want to push back more clearly before I give it a rest for another few years.

(1)

If you are reading this, you probably know that the primary purpose of final in Java is not as a performance hint, but for contract enforcement (as taught in intro software engineering, etc). What you may not know (or may misremember) is, it's not a secondary purpose of final, either! Using final to try to speed up methods or classes has been discouraged, and not worked, since the Java HotSpot compiler came out in 1999. Here is a column from October 2002 debunking the myth that one should consider using final for performance reasons (referenced by a 2003 column titled Urban performance legends):

Like many myths about Java performance, the erroneous belief that declaring classes or methods as final results in better performance is widely held but rarely examined. The argument goes that declaring a method or class as final means that the compiler can inline method calls more aggressively, because it knows that at run time this is definitely the version of the method that's going to be called. But this is simply not true. Just because class X is compiled against final class Y doesn't mean that the same version of class Y will be loaded at run time. So the compiler cannot inline such cross-class method calls safely, final or not. Only if a method is private can the compiler inline it freely, and in that case, the final keyword would be redundant.

On the other hand, the run-time environment and JIT compiler have more information about what classes are actually loaded, and can make much better optimization decisions than the compiler can. If the run-time environment knows that no classes are loaded that extend Y, then it can safely inline calls to methods of Y, regardless of whether Y is final (as long as it can invalidate such JIT-compiled code if a subclass of Y is later loaded). So the reality is that while final might be a useful hint to a dumb run-time optimizer that doesn't perform any global dependency analysis, its use doesn't actually enable very many compile-time optimizations, and is not needed by a smart JIT to perform run-time optimizations.

In summary, final: Not a performance thing, at all.

Not a way to "request a low-level runtime behavior" that happens to "imply a type meaning," as Ryan argued.

(Note that final fields are a different story; the point here is about final classes and methods.)

(2)

When asked if, while considering the pros and cons of final methods and final classes, we could discuss them as separate features, Ryan replied:

I don't see any lack of duality between final classes and final methods; the latter is simply a more fine-grained control of the former and the motivations are largely the same.

However, they are separate features, not essentially the same, because:

  • They do different things.
  • If I can't have one, for some technical or moral reason, I still want the other.
  • It's a lot easier to make sure people don't subclass a class than to make sure they don't override a method, such as by accident or after a refactoring, just because it's a harder mistake to make in the first place.
  • Enforcement-wise, it's a lot easier to work around lack of final classes, e.g. with private constructors or—I haven't used this, but I imagine—a runtime check like this.constructor === MyClass.
  • Google's Dart language (as a point of reference) addresses them in two different ways, a final class modifier and a @nonVirtual method annotation.

(3)

Ryan also wrote:

The general consensus on final classes was that from a type perspective, interactions between a base class and its derived classes are a complex interplay that can't be accurately described by providing a few keywords. By that measure, final of any form would be a "half measure" that doesn't really completely solve what is really an extremely complex problem.

It's hard to see how any set of modifier keywords in any language could be said to "completely solve" the "extremely complex problem" of the "interactions between a base class and its derived classes." As others have pointed out, by this standard, modifiers like override and protected would be condemned as "half measures" and not implemented. It's a heavy-handed argument that is hard to take at face value.

(In Dart, by the way, @override and @protected are annotations—similar to decorators—not keywords, and I'm in a thread right now about what it would take to make protected a language keyword, which would be slightly less clunky in certain ways. Part of the complexity being raised is that any core language feature, for philosophical reasons, would have to be implemented in the runtime, too, with runtime checks. There isn't the same split of TypeScript and TC39 being different parties, and the Dart team has expressed low-priority willingness to eventually bring protected into the language, maybe with some modifications, but there is a similar sense of "you can't have nice static things because that sounds like something the runtime could get in on." Different languages seem to have different versions of this. Anyway, it could be worse, TypeScript could be refusing to implement protected, or the definitely-wouldn't-be-added-today private.)

Runtime enforcement should not be a major consideration, IMHO. The point of features like this is to aid programmers working together and reduce bugs. I realize there is the relationship with the evolution of ECMAScript/TC39 to consider, but it's so ironic, considering that TypeScript is all about adding static guarantees to your code that are utterly unchecked at runtime.

(I know I keep bringing up Dart, but it's just a really interesting language to compare. Did you know that in Dart, operators like ! and as emit runtime checks?? There is a concept of "runtime soundness," where if there are holes in the static type system, they must be plugged at runtime. This is partly because Dart doesn't just compile to JavaScript, it can be AOT-compiled to machine code. It is very hard in Dart for your static types and your runtime types to differ at all—even type parameters are reified at runtime—while in TypeScript, it is super easy; you can set up a static fantasy land if you want, and it will never be checked. So that's part of why it's ironic.)

I sort of understand the argument that class features are the domain of ECMAScript, while type features are the domain of TypeScript, but... we have override. And TC39 doesn't seem to be working on final? TypeScript implementing something here might even lead the way as far as TC39.

Update: TC39 discussion / Suggestion to use decorators!

I just found this: TC39 Discourse: Final classes

The current thinking seems to be, final classes (and methods) can now probably be implemented with decorators. They'll see if that takes off.

I would argue this puts the ball in TypeScript's court as far as how to check these decorators statically. Or, why not have a compiler flag where the "final" keyword adds a decorator? Or, to save work, don't bother emitting decorators in the first version; see if people are dissatisfied with just static enforcement. I just want squiggles in my IDE.

IMHO, it doesn't make sense for TypeScript to wait for TC39 to consider adding a final keyword to the language, when TC39 only needs to be concerned with runtime enforcement, and they are waiting to see if decorators solve that problem. At the end of the day, there's no uncertainty about what the semantics of final would be, anyway, I don't think, so no downside in TypeScript moving forward, whether it's a keyword or a decorator.

@AshGw
Copy link

AshGw commented May 4, 2024

Decorators kinda solve the problem, but not fully, already made a class decorator (definition, how it works), not perfect though, but gets the job done.

  @Final
  class Foo<T> {
    foo: T;

    constructor(foo: T) {
      this.foo = foo;
    }
    someFoo(): T {
      return this.foo; 
    }
  }

  class SubFoo extends Foo<string> {
    constructor(foo: string) {
      super(foo);
    }
  }


const _ = new SubFoo('subbedFoo');

This will cause a runtime TypeError: Cannot inherit from the final class at Foo ... at SubFoo ... (The error message is not perfect yet).
Wouldn't it be better to error out at compile time ?

  final class Foo<T> {
    foo: T;

    constructor(foo: T) {
      this.foo = foo;
    }
    someFoo(): T {
      return this.foo; 
    }
  }

  class SubFoo extends Foo<string> {} // You can't even do this, TS will complain 
   

Same for methods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Suggestion An idea for TypeScript Won't Fix The severity and priority of this issue do not warrant the time or complexity needed to fix it
Projects
None yet
Development

No branches or pull requests