Skip to content

Allow interfaces to declare members that inheritors must define #56774

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

Open
6 tasks done
Tbhesswebber opened this issue Dec 14, 2023 · 2 comments
Open
6 tasks done

Allow interfaces to declare members that inheritors must define #56774

Tbhesswebber opened this issue Dec 14, 2023 · 2 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@Tbhesswebber
Copy link

πŸ” Search Terms

interface enforce, interface abstract, interface inherit

βœ… Viability Checklist

⭐ Suggestion

Allowing interfaces, especially those exported by libraries, to specify mandatory, user-defined type-narrowing would make many situations simpler, removing some of the hoops that developers jump through to defer type resolution.

This might look something like:

interface Foo {
	abstract bar: unknown;
	baz: string;
}

const foo: Foo = {bar: 0, baz: "a"}; // errors - interface Foo has unset abstract property

interface Bar extends Foo {} // errors - property `bar` is missing

// all properties within Foo can be narrowed
interface Baz extends Foo {
	bar: number;
	baz: "hello" | "world";	
}

For a more concrete example, we might revisit the Shape example from the TS docs:

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

This is nice, but it runs into a problem - Circle and Square can't share any properties because we've removed inheritance from the equation, so area and perimeter would need to be defined separately on each or defined on a shared, extended interface like BaseShape. Instead, we can stick with the original inheritance patterns and enforce properties further down the inheritance chain.

interface WithKind {
	abstract kind: string;
}

interface Shape {
	area: number;
	perimeter: number;
}

interface ShapeWithKind extends Shape, WithKind {
	kind: string;
}

interface Square extends Shape, WithKind {
	kind: "square",
	sideLength: number;
}

interface Circle extends Shape, WithKind {
	kind: "circle",
	radius: number;
}

interface Triangle extends Shape, WithKind { // error - missing property `kind`
	sideLengths: [number, number, number];
}

When it comes time to consume this via a function, we can specify if we need a Shape or ShapeWithKind or maybe some shapes have a family property, extending Shape, WithKind, and WithFamily. This is somewhat reminiscent of Dart's abstract class mixins but exclusively looks at the implications on the type system, disregarding the entirety of the runtime impact of that inspiration.

πŸ“ƒ Motivating Example

Abstract classes let you add base behavior so programmers don't have to code everything, while still forcing them to follow your design. The new interface properties allow the same within the type system, improving the developer experience for consumers of your module as well as allowing more robust mechanisms to defer type resolution to the point of use rather than the point of definition.

πŸ’» Use Cases

  1. What do you want to use this for?
    I want to use this to enforce the shape of an interface without providing all of the type information at the point of use. A colleague wants to effectively write interface Foo {...} satisfies Bar, which would be possible with interface Foo extends AbstractFoo {...}. If paired with Add an absent type forbidding presenceΒ #55143 (or even just judicious use of the never bottom type) you could apply full constraints to inheriting interfaces in terms of what members must be defined and what members must not be, which seems especially powerful for overload resolution.

  2. What shortcomings exist with current approaches?
    The only way that I know of to do something similar is to use a dummy utility type, which then means you're either separating errors from interface signatures or defining everything as a type alias and losing the value of interfaces.

  3. What workarounds are you using in the meantime?

type Ensure<TEnforcedInterface, TInterface extends TEnforcedInterface> = TInterface;

interface Enforced {
	foo: unknown;
	bar: unknown;
}

interface IEnforceMe {
	foo: string;
}
type EnforceMe = Ensure<Enforced, IEnforceMe>; // errors report on this line

type EnforceMe2 = Ensure<Enforced, {foo: string}>; // errors report here, but all consuming code won't see the `EnforceMe2` name
@fatcerberus
Copy link

For some reason I thought you could use implements with interfaces, which would mostly solve this use case, but apparently it's not allowed.

@Tbhesswebber
Copy link
Author

For some reason I thought you could use implements with interfaces, which would mostly solve this use case, but apparently it's not allowed.

@fatcerberus - Yeah, I'm basically proposing implements, but it would be weird to hijack that keyword just because interfaces don't technically implement anything

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Dec 14, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants