0

I have an interface that looks like this:

export interface Attribute {
  name: string;
}

With this I can use this syntax to create an Attribute:

const attr: Attribute = {
  name: "foo"
};

My problem is that I don't want to use classes because they are clumsy and not flexible enough, but I want to keep using the syntax above for creating objects because it is very succinct. I've read that it is possible to do declaration merging so I tried to do this to add a new function to this interface:

import { Attribute } from "./Attribute";

declare module "./Attribute" {
  interface Attribute {
    matches(other: Attribute): boolean;
  }
}

Attribute.prototype.matches = function (other: Attribute): boolean {
  return this.name === other.name;
};

The problem here is that there is no prototype to augment since interfaces are not available at runtime. If I merge the Attribute interface with a class:

export class Attribute {}

export interface Attribute {
  name: string;
}

there is a prototype and the augmentation compiles but I still can't use it:

import { Attribute } from "./Attribute";
import "./AttributeAugments";

const attr: Attribute = {
  name: "foo"
};
// ^^^--- Property 'matches' is missing in type '{ id: number; name: string; }' but required in type 'Attribute'

console.log(attr);

Is there a way to somehow do this? What I'm aiming for is to be able to do attr.matches(other) instead of having to do matches(attr, other). This way I could do function chaining like attr.a().b().c() instead of having to do c(b(a(attr))).

2
  • do you know upfront all methods of attr? Commented Aug 26, 2021 at 7:56
  • 1
    You say that classes are not flexible enough, but it looks like you are just trying to create a class, but implicitly :) Commented Aug 26, 2021 at 8:52

2 Answers 2

2

From your code, Attribute is an interface. A type or interface compiles to nothing in JavaScript; therefore, you can't do like this in your question.

declare module "./Attribute" {
  interface Attribute {
    matches(other: Attribute): boolean;
  }
}

// No such "Attribute" variable!
Attribute.prototype.matches = function (other: Attribute): boolean {
  return this.name === other.name;
};

const attr: Attribute = {
  name: "foo"
};
attr.matches(); // error, attr's prototype is Object, not Attribute
// because there is no constructor function or class Attribute

There are some ways to solve your problem.

(1) Always include matches method every time you create a variable of type Attribute:

export function matches(this: Attribute, other: Attribute): boolean {
  return this.name === other.name;
};
const attr: Attribute = {
  name: "foo",
  matches,
}; // works

(2) Use Attribute class but simplify its instantiation:

type ExcludeMethods<T extends object> = Pick<
  T,
  {
    [x in keyof T]: T[x] extends Function ? never : x;
  }[keyof T]
>; // without this type, "matches" method is required in the object literal!

class Attribute {
  constructor(init: ExcludeMethods<Attribute>) {
    Object.assign(this, init);
  }

  name: string;

  matches(other: Attribute): boolean {
    return this.name === other.name;
  }
}

const attr = new Attribute({ name: "foo" }); // works
const attr2 = new Attribute({}); // error, name is required

If name: string property causes compilation error, add non-null assertion (so that it becomes name!: string) since we are pretty sure that non-function fields must be present to Object.assign to this.

Sign up to request clarification or add additional context in comments.

Comments

1

I'm not quite sure you need that complex declaration merging; a simpler explanation is that "interfaces are open-ended (cite)" so you can add functionality to existing interfaces.

Example:

interface Attribute {
    name: string;
}

interface Attribute {
    matches(other: Attribute): boolean;
}

const attr1: Attribute = {
    name: "foo",
    matches: function(other: Attribute): boolean {
        return this.name === other.name;
    }
}

const attr2: Attribute = {
    name: "foo",
    matches: function(other: Attribute): boolean {
        return this.name === other.name;
    }
}

console.log(attr1.matches(attr2));

FYI, you would actually be better with classes for this. In my first example, I'm violating the DRY principle by repeating the matches implementation for each object literal. When using a class, the matches function is bound to the prototype, therefore there's no repetition.

Example:

class Attribute {
    constructor(public name: string) {
    }

    // Typically this is called `equals` in other languages; i.e. Java, Kotlin, C#...
    public matches(other: Attribute): boolean {
        return this.name == other.name;
    }
}

const attr1 = new Attribute("foo");
const attr2 = new Attribute("foo");

console.log(attr1.matches(attr2));

If you really wanted to declare objects like that, you can also do this with the class (although it's not really conventional). The following code is an extension of the example, above.

Example:

const attr3: Attribute = {
    name: "bar",
    matches: Attribute.prototype.matches
}

If you REALLY don't want to use classes, a create function might help (roughly equivalent to a class constructor).

Example:

interface Attribute {
    name: string;
}

interface Attribute {
    matches(other: Attribute): boolean;
}

function createAttribute(name: string): Attribute {
    return {
        name: name,
        matches: function(other: Attribute): boolean {
            return this.name === other.name;
        }
    }
}

const attr1: Attribute = createAttribute("foo");
const attr2: Attribute = createAttribute("bar");
const attr3: Attribute = createAttribute("foo");

console.log(attr1.matches(attr2));
console.log(attr1.matches(attr3));

2 Comments

The point is that I don't want to redeclare matches every time. That's why I tried adding it to the prototype, but of course interfaces have no runtime representations so I couldn't do it.
Thanks. I think I'll just use plain functions defined in the module where the type is. This is too much hassle just to be able to do foo.bar instead of bar(foo). Thanks!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.