Skip to content

Different types based on visibility #43553

Open

Description

@Jet132

Suggestion

🔍 Search Terms

  • Different types based on visibility
  • Different types based on private, protected properties

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Make properties (and getters/setters) be able to have different types based on where they are accessed from (AKA visibility).
It should have the following rules:

  • The least visible (relative from where it's being accessed) type should be used when accessing/setting the property (private over protected over public).
  • This should only be a type difference and not change the actual js value that is returned.
  • Only the least visible type can be writable.
  • The more visible types need to at least include the least visible type.
  • Properties can only be initialized on their least visible version
  • Types need to be ordered from most visible to least visible
class Foo {
  public readonly prop1: ReadonlyArray<unknown>;
  private readonly prop1: unknown[] = [];
  // Implicitly also means:
  // protected readonly prop1: ReadonlyArray<unknown>;

  public readonly prop2: number;
  protected prop2: number;
  // Implicitly also means:
  // private prop2: number;

  // Error: Only the least visible type can be writeable
  protected prop3: number | string;
  private prop3: number;

  // Error: More visible types need to include the least visible type
  protected readonly prop4: string;
  private prop4: number;

  // Error: Property types need to be ordered from most to least visible
  private readonly prop5: unknown[] = [];
  public readonly prop5: ReadonlyArray<unknown>;

  // Error: Properties can only be initialized on their least visible type
  public readonly prop6: ReadonlyArray<unknown> = [];
  private readonly prop6: unknown[];

  fooMethod() {
    // Works
    this.prop1.push(1);
    // Works
    this.prop2 = 2;
  }
}

class FooBar extends Foo {
  fooBarMethod() {
    // Works
    const length = this.prop2.length;
    // Error: Property 'push' does not exist on type 'readonly unknown[]'
    this.prop1.push(1);
    // Works
    this.prop2 = 2;
  }
}

const foo = new Foo();
// Works
const length = foo.prop2.length;
// Error: Property 'push' does not exist on type 'readonly unknown[]'
foo.prop1.push(1);
// Works
const prop2Value = foo.prop2;
// Error: Cannot assign to 'prop2' because it is a read-only property.
foo.prop2 = 2;

📃 Motivating Example

It removes the need of using custom public/protected getters back by protected/private properties reducing a lot of clutter and repetitive "dumb" code when implementing a strictly typed API.

💻 Use Cases

What do you want to use this for?

Class APIs where you can see the inner state but may only mutate it through the provided method calls.

What workarounds are you using in the meantime?

Getters backed by properties prefixed with _.

class Foo {
  private _prop: number;
  get prop(): number {
    return this._prop;
  }
}

Activity

  1. MartinJohns commented on Apr 6, 2021

    @MartinJohns
    Contributor

    It sounds like you want #37487.

    Otherwise this proposal strikes me as really odd. How would this behave?

    private prop1: string;
    public prop1: number;
    
  2. Jet132 commented on Apr 6, 2021

    @Jet132
    Author

    Not only do I want to make some properties be readonly when accessed publicly but I also want them to change their type. For example from Array to ReadonlyArray or from Map to ReadonlyMap. There will most likely need to be restrictions on how different those types can be. Like the more visible types need to be more open than the least visible type and only the least visible type can be writeable.

    Though those might sound like quite strict rules they are applicable to most common use cases (at least for me) where you want to secure the mutability of a property.

  3. paul-marechal commented on Apr 6, 2021

    @paul-marechal

    There will most likely need to be restrictions on how different those types can be. Like the more visible types need to be more open than the least visible type and only the least visible type can be writeable.

    Codec (@Jet132) For sure such constraints will have to be met.

    Otherwise this proposal strikes me as really odd. How would this behave?

    private prop1: string;
    public prop1: number;
    

    Martin Johns (@MartinJohns) the example you wrote should result in an error because string is not compatible with number. Like the type 'abc' is compatible with string, but string is not compatible with 'abc'. The same constraints should be respected where more closed declarations must be compatible with more opened ones.

  4. paul-marechal commented on Apr 6, 2021

    @paul-marechal

    Although thinking about it more, there is something odd, see:

    class {
        private prop: 'abc'
        public prop: string
    }

    In such a case, the public member can be assigned any string, violating the private 'abc' constraint. Does that mean that both types have to either be the same to be mutable settable, and if different more open declarations must be readonly?

  5. Jet132 commented on Apr 6, 2021

    @Jet132
    Author

    If you look at my first comment I actually suggested only having the least visible type be mutable. I'll change the issue proposal to fit it

  6. paul-marechal commented on Apr 6, 2021

    @paul-marechal

    I had missed it, good to mention it in the proposal.

    Another question is where would initialization be done? Only on the least visible type? Should the declaration order matter?

    class {
        private prop = 'abc' as const
        public readonly prop: string
    }
  7. Jet132 commented on Apr 6, 2021

    @Jet132
    Author

    The initialization should be done in the least visible type and about the declaration order, I'm not sure but it should matter for readability. We can think of the more visible types like function overloads just for visibility. Would it then make sense to order it from most to least visible to have the initialization at the bottom?

  8. paul-marechal commented on Apr 6, 2021

    @paul-marechal

    Would it then make sense to order it from most to least visible to have the initialization at the bottom?

    Sounds good to me at least. Like you said it would be similar to functions overloads.

    class {
        public readonly prop: IThing
        private prop: Thing = new Thing()
    }

    Lastly, what would it mean to declare all 3 scopes?

    class {
        public readonly prop: '?'
        protected readonly prop: '??'
        private prop: '???'
    }
  9. MartinJohns commented on Apr 6, 2021

    @MartinJohns
    Contributor

    having the least visible type be mutable

    TypeScript has no way to declare types as mutable / immutable. This would require something like #17181.

  10. robbiespeed commented on Apr 6, 2021

    @robbiespeed

    If the ergonomics were improved for the cases where the value type doesn't change between access levels, then I would prefer this proposal over #37487. One suggestion, is if the value type was implicit for following declarations of the same property.

    class Foo {
      public readonly prop: string;
      private prop; // inferred as string 
    }

    If you look at my first comment I actually suggested only having the least visible type be mutable. I'll change the issue proposal to fit it

    Codec (@Jet132) rather than requiring that only least visible is mutable which would really limit the usefulness of this proposal, what if types were constrained at mutation to use a intersection of all types across access levels.

    class Foo {
      public prop: string;
      private prop: 'abc';
    }
    const foo = new Foo();
    foo.prop = 'abc'; // prop is constrained to ('abc' & string) which results in 'abc'
    foo.prop // type is still string

    This would be helpful in handling non primitives as well.

    class Box <T> {
      public readonly value: T;
      private value;
      constructor (value: T) {
        this.value = value;
      }
      set (value: T) {
        this.value = value;
      };
    }
    interface ReadonlyBox <T> {
      readonly value: T;
    }
    class Foo {
      public prop: ReadonlyBox<string>;
      private prop: Box<string> = new Box();
    }
    const foo = new Foo();
    foo.prop = { value: 'abc' }; // TypeError because it doesn't satisfy (Box<string> & ReadonlyBox<string>)

    This eliminates the risk that prop could be replaced with a less capable object which would cause errors when a Box attempts to use the set method inside the Foo class.

  11. paul-marechal commented on Apr 6, 2021

    @paul-marechal

    TypeScript has no way to declare types as mutable / immutable.

    Martin Johns (@MartinJohns) I think we used the wrong terminology, we meant changing the value of the property. By "non-mutable" what we meant was "non-settable" as in prefixing with readonly.

  12. paul-marechal commented on Apr 6, 2021

    @paul-marechal
    class Foo {
      public prop: string;
      private prop: 'abc';
    }
    const foo = new Foo();
    foo.prop = 'abc'; // prop is constrained to ('abc' & string) which results in 'abc'
    foo.prop // type is still string

    Robbie Speed (@robbiespeed) this means leaking implementation details and breaking encapsulation? I am not in favor of this. Whenever you want to change implementation details, what is effectively used publicly might break.

    Although I'd be fine with the type inference.

  13. Jet132 commented on Apr 6, 2021

    @Jet132
    Author

    Lastly, what would it mean to declare all 3 scopes?

    class {
        public readonly prop: '?'
        protected readonly prop: '??'
        private prop: '???'
    }

    This would just make every scope have a different type. Note that the example above would throw an error because of the inclusive rule.
    It would need to be at least the following:

    class {
      public readonly prop: '?' | '???';
      protected readonly prop: '??' | '???';
      private prop: '???';
    }

    Codec (@Jet132) rather than requiring that only least visible is mutable which would really limit the usefulness of this proposal, what if > types were constrained at mutation to use a intersection of all types across access levels.

    class Foo {
      public prop: string;
      private prop: 'abc';
    }
    const foo = new Foo();
    foo.prop = 'abc'; // prop is constrained to ('abc' & string) which results in 'abc'
    foo.prop // type is still string

    Robbie Speed (@robbiespeed) I agree with @marechal-p. The type should be explicitly set. It also prevents confusion of why public prop suddenly has the type 'abc' not string as explicitly specified.

    The second example doesn't make much sense as you are restricting the consumer of the class from modifying an object which it already has the modifyable instance of. What use would it be to restrict the user from modifying it via the property?
    btw. In you example, it would make the box.value property readonly but expose the box.set() method completely negating the readonly part of it.

    There are no real usecases I can think of where this behavior would actually be needed. As I've mentioned in my first comment the rules do seem quite strict but in practice (at least in mine and those I can imagine) you won't be needing the features for anything more.

    Edit: and I'm also fine with type inference 👍

  14. Jet132 commented on Apr 6, 2021

    @Jet132
    Author

    I've updated the proposal but without the type inference. There I'm not sure if it wouldn't be better to have the inference from bottom to top as you can only initialize it on the bottom.

    class Foo{
        // Implicitly of type string
        public readonly prop1;
        private prop1: string;
    
        // Implicitly of type number
        public readonly prop2;
        private prop2 = 1;
    }

    This would make type inference even more useful though I'm not sure if typescript has an unwritten rule that type inference needs to be done from top-left to bottom-right.

  15. ExE-Boss commented on Apr 6, 2021

    @ExE-Boss
    Contributor

    Codec (@Jet132)
    Note that you need to use the declare modifier, since:

    class Foo {
    	// Explicitly of type string
    	public readonly prop1;
    	private prop1: string;
    
    	// Implicitly of type number
    	public readonly prop2;
    	private prop2 = 1;
    }

    compiles to:

    class Foo {
    	// Explicitly of type string
    	prop1;
    	prop1;
    
    	// Implicitly of type number
    	prop2;
    	prop2 = 1;
    }

    whereas:

    class Foo {
    	// Explicitly of type string
    	declare public readonly prop1;
    	private prop1: string;
    
    	// Implicitly of type number
    	declare public readonly prop2;
    	private prop2 = 1;
    }

    compiles to:

    class Foo {
    	// Explicitly of type string
    	prop1;
    
    	// Implicitly of type number
    	prop2 = 1;
    }
  16. Jet132 commented on Apr 6, 2021

    @Jet132
    Author

    Well, the optimal implementation would not require the declare. Just like function overloads don't require it.

  17. robbiespeed commented on Apr 7, 2021

    @robbiespeed

    I've updated the proposal but without the type inference. There I'm not sure if it wouldn't be better to have the inference from bottom to top as you can only initialize it on the bottom.

    I think it would be best if order wasn't enforced, or instead of going from most to least visible it went from least to most. It seems clearer to me when initialization and/or type definition can come first.

    class Foo {
      private prop = 1;
      public readonly prop;
    }
  18. Jet132 commented on Apr 7, 2021

    @Jet132
    Author

    hmm tbh, I would rather keep the order from most to least visible. It's more consistent with function overloads and also gives a visual indication of how the feature works. Like any not specified visibility just implicitly inherits that from the above (if there is one).

    class Foo {
      public readonly prop;
      // protected readonly prop;
      private prop = 1;
    }

    Maybe we should consider leaving out the type-inference to be even more consistent with function overloads and also with typescript's tendency to infer from left to right, top to bottom.

  19. Fasteroid commented on Nov 14, 2024

    @Fasteroid

    Hate to necro this, but I'd really love to see this too. I'm running into Jet's exact problem with "dumb" getter/setters that shouldn't have to exist. Any plans for this yet?

  20. denis-migdal commented on Jul 6, 2025

    @denis-migdal

    +1 this would indeed be a quite useful feature.

  21. denis-migdal commented on Mar 3, 2026

    @denis-migdal

    I discussed a similar feature here, going a little more in depth into the technical aspect and behavior.

    Other workarounds:

    Workaround 1: use an identity function.

    function asInternal(a: R): W { return a as any; }
    
    class Foo {
          readonly foo: R = ...;
          faa() {
                asInternal(foo).doStuff
          }
    }

    However, this is kind of fragile if the attribute is redefined in the subclasses.

    Depending on the case, we can use a asMutable() function (removes readonly), or a asRW() :

    import "asRW"
    declare module "asRW" {
        export interface TasRW {
            <T>(ro: REvent<T>): REvent<T>&WEvent<T>
        }
    }
    
    // in asRW
    export interface TasRW {}
    function asRW(a: unknown) { return a; }
    
    export default asRW as TasRW;

    Workaround 2:

    Add an internal interface:

    interface Foo {
        foo: R;
    }
    interface InternalFoo extends Foo {
        foo: W;
    }
    
    class ImplFoo implements InternalFoo { ... }
    
    export function createFoo(): Foo { return new ImplFoo(); }
    // or
    const XFoo = ImplFoo as {new(): Foo}
    export default XFoo;

    This requires to declare interfaces (not a bad thing in itself).

    This makes subclassing a little more difficult as we have to remove the internal properties before exposing the new class.

    However, when using helpers using internal features, e.g. :

    function helper(f: Foo) {
          (f as InternalFoo).DO_STUFF()
    }

    This might cause issues as we could give a class implementing Foo, but not InternalFoo.

    Making Foo and InternalFoo as abstract classes, with an abstract property in Foo, only defined in InternalFoo could work, but this starts to be quite hacky.

  22. denis-migdal commented on Mar 5, 2026

    @denis-migdal

    Workaround 3: use an internals property

    class Foo {
          readonly foo: R = ...;
          protected readonly internals = this as { foo: W };
    
          faa() {
                this.internals.foo;
          }
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions