#Extending prototypes in a type-safe way (extension methods)

100 messages · Page 1 of 1 (latest)

noble stone
#
// extensions.ts
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Element
{
    foo(): this;
}
Element.prototype.foo = function()
{
    return this;
};

// other-file.ts
import "./extensions.js";
new Element().foo();

This works, but it has two problems:

  1. You don't get any compile-time error even if you forget to implement foo().
  2. You don't get any compile-time error even if you forget to import the file.
    Are there any remedies to these issues?
timid ridge
#

Not that I know of, and this is generally why prototype pollution is frowned upon.

#

The "forget to implement" is less of a problem, as you typically colocate the declaration merging code and the implementation code so it's not very likely to happen

noble stone
#

Yeah, but #2 is more of an issue

timid ridge
#

But the "forget to import" is the bigger issue, and it's even worse that not just needing to import, but you must import before any other code that uses the added prototype functions.

noble stone
#

Yeah. Would it be possible to write a custom ESLint rule to protect from that?

timid ridge
#

Hmm not sure, you'd have to somehow specify the entry point to the program, since that information is not knowable by only analysis.

noble stone
#

I was thinking even of a more restrictive rule, where you're forced to import extensions in each file which uses them

#

Then you shouldn't need to know the entry point I think?

stray ember
#

how would you tell which are extensions though?

noble stone
#

I don't know the capabilities of ESLint rules, so I don't know, hence my question :p

stray ember
#

computers can't really do more than humans can

#

well, it's complicated, but let's stick to that

timid ridge
#

I would think TS ESLint has ways to track a type back to its source file, so if you make the rule that "the declaration merging code has to colocate with the implementation in the same file" then you could look at Element#foo and say "you must import the file that defines Element#foo"

stray ember
#

a human looking at the code wouldn't know which ones are extensions (say, vs polyfills or just nonstandard builtin stuff)

timid ridge
noble stone
#

Fluent syntax is so convenient though 😭

stray ember
#

so eslint, with even less contextual information, would not be able to tell which are extensions just from the code
is some assignment or declaration merge an extension, or a polyfill, or a declaration for a non-standard feature, or a polyfill for a non-standard feature, etc

stray ember
timid ridge
#

Tbh if you go down this path of adding things to prototype, I'd say it's a good case of solving with "convention over restriction."

noble stone
#

Would this be doable with custom ESLint rules?

stray ember
#

what

#

that's not the issue im presenting

noble stone
#

I thought you meant it's impossible to say which interfaces extend existing types and which don't?

timid ridge
stray ember
#

you don't have extending interfaces there?

noble stone
#
interface Element
{
    foo(): this;
}
stray ember
#

the interface Element is a merged declaration

noble stone
#

Well, I don't know the correct nomenclature, but this is what I meant

stray ember
#

interface CustomElement extends Element would be an extension
by "extension" i'm referring to the runtime extension, Element.prototype.foo = /someElement.foo()

stray ember
noble stone
#

Well, I think ESLint should see whether foo() is called or not?

stray ember
#

how would eslint know foo is an extension method?

noble stone
#

Now I'm talking about applying this to all interfaces

stray ember
#

a human can't know without outside knowledge, so eslint can't know if foo is an extension method or something else

timid ridge
noble stone
#

If you use a method from an interface, and file with that interface isn't imported -> error

stray ember
#

string and array methods are on interfaces

#

function methods are on its interface

#

i don't think that'd be a very pleasant DX

noble stone
#

I was thinking the rule would only analyze my own code

#

As in it would only find all interfaces in my code

stray ember
#

yeah that's how eslint works

noble stone
stray ember
#

but like, String and Array and Function and Number are all still interfaces

noble stone
#

These interfaces aren't defined in my code is what I mean

#

Ideally the rule would only restrict usages of interfaces defined in my code

#

If that's possible

stray ember
noble stone
#

Well, I can make the distinction :p

stray ember
#

interface Element is defined both in your code and the library code

noble stone
#

Yeah, so the rule would apply to it

stray ember
noble stone
#

Well, no

#

I just know which files are compiled by TypeScript

#

Interface is defined in these files => it's my code

stray ember
#

...exactly

#

it's your code specifically

noble stone
#

And computer can have that knowledge, too, right?

#

It can just look at tsconfig

stray ember
#

an arbitrary human with no inside knowledge of your code, just looking at the single file that uses Element, could know of its declaration within the library, but that human wouldn't know of your custom declaration

stray ember
noble stone
#

Well, yeah

#

So it's doable by a computer. Whether it's doable by ESLint I don't know, which is why I ask :p

timid ridge
stray ember
noble stone
stray ember
#

i mean it would be if you just go by name, but ts doesn't really care about names

noble stone
#

Well, that'd be good enough for the rule, if it worked this way

stray ember
#

...i feel like this is becoming another flavor of the halting problem

noble stone
#

It doesn't need to be perfect

stray ember
#

honestly i feel like just making a util class would be a lot easier than doing the extension thing

noble stone
#

So like I'd have a wrapper which would have a property like Element which would return the actual Element?

stray ember
#

this thing is weird and complicated because it's not really something intended or often-used

stray ember
noble stone
#

Yeah, I wish extension methods were a thing in TS

timid ridge
stray ember
#

i mean just something like this

const elementHelpers = {
  foo(elem: Element) { /* whatever `Element#foo` was supposed to do */ }
};
#

that's far more typesafe

#

(doesn't need to be a class, i was just thinking in java for a sec there)

noble stone
#

Oh, that's roughly how I have it set up right now

#

But I wanted fluent syntax :(

stray ember
#

TIL that's a term

noble stone
#

element.a().b().c() is a lot nicer than c(b(a(element)))

noble stone
stray ember
#

since Element's existing methods aren't really meant to be fluent, wouldn't this just be kinda shoehorning it

stray ember
stray ember
#

yeah

noble stone
#

It's one of the options I've considered, but wasn't a fan of having to adjust every single place which uses/returns elements, to use/return the wrapper instead

#

I think I'll go with what Burrito suggested, that seems like the lesser evil, all things considered

#

Anyway, thanks for the brainstorming

#

!resolved

noble stone
#

I've just had an idea for a module:

There'd be a function decorator like @extension-method or whatever, which would enable fluent syntax for the function calls.
And a transformer would convert all such calls to normal function calls.

It's probably not easy to make, but it sounds like a nice side project many people could make use of.

#

Not only would it be fully type-safe, but it would also allow extending types which don't exist at runtime or extending nullable types.

#

I'm honestly surprised it doesn't exist yet

#

Or maybe it does exist and I just don't know where to look for it :p