Home > Software design >  Unique property values between instances of classes in JavaScript/TypeScript
Unique property values between instances of classes in JavaScript/TypeScript

Time:01-24

Given an interface IThing

interface IThing {
    name: string;
}

Is there a way to ensure that classes which implement IThing use unique values for name?

I.e.

class ThingOne implements IThing {
    name = "Name_One"
}

class ThingTwo implements IThing {
    name = "Name_Two"
}

Should be allowed, while

class ThingOne implements IThing {
    name = "Name_One"
}

class ThingTwo implements IThing {
    name = "Name_One"
}

Should result in a TypeScript error.

The property-values are meant to be readable and hand-written as they describe the object itself so they can't be generated.

CodePudding user response:

I see only decision with using Set to track unique names

let usedNames = new Set<string>();
class ThingOne implements IThing {
    name: string;
    constructor(name: string) {
        if (usedNames.has(name)) {
            throw new Error(`Name "${name}" has already been used.`);
        }
        usedNames.add(name);
        this.name = name;
    }
}

CodePudding user response:

TypeScript doesn't really have any built-in way to ensure uniqueness of properties across different classes, nor does it even keep track of which classes extend/implement other classes/interfaces in a way that the developer can query (the structural type system means that a class can implement an interface even if you don't declare it as such, and so the set of things that implement an interface is potentially infinite, unlike in nominal type systems).

So if you want some guarantee like this, you'll need to significantly refactor your code to press some other TypeScript functionality into service instead, as a workaround.


One possible approach is to store your classes in an registry object, and add each class to the object by calling an assertion function that both checks the new class against the existing ones for correctness/uniqueness, and also records that information in the registry object's type via narrowing. Maybe like this:

interface IThing {
    name: string;
    prop: string;
    generate(): string;
}

type ValidateUniqueThing<TS extends IThing, T extends IThing> = {
    [K in "name" | "prop"]:
    string extends T[K] ? [Error, "Make", K, "readonly PLEASE"] :
    T[K] extends TS[K] ? [Error, "Conflicting ", K, "Property with class named ",
        Extract<TS, { [P in K]: T[K] }>["name"]] :
    T[K]
}

function registerThing<R extends Record<keyof R, IThing>, T extends IThing>(
    registry: { [K in keyof R]: new () => R[K] },
    name: T['name'],
    newThing: new () => (
        T extends ValidateUniqueThing<R[keyof R], T> ? T : ValidateUniqueThing<R[keyof R], T>
    )
): asserts registry is Extract<
    { [K in keyof R | T['name']]: new () => K extends keyof R ? R[K] : T },
    typeof registry
> {
    Object.assign(registry, { [name]: newThing });
}

So, the utility type ValidateUniqueThing<TS, T> takes a union of existing Thing types TS, and a candidate for a new addition, T. If T is an acceptable addition, then ValidateUniqueThing<TS, T> evaluates to just T. Otherwise, the property of T that violates a rule will be mapped to an incompatible type whose displayed info hopefully gives the developer some information about how to fix the problem.

There are two main possible violations here:

  • If the property is of type string instead of a literal type, then the compiler has no idea what the value will actually be, and it won't be able to guarantee uniqueness. This usually happens when you initialize a property with a string literal without marking the property as readonly, so the incompatible type mentions readonly (but you could change this to whatever you want).

  • If the property already exists in the union of existing literal types for the same property in the other registered classes, then the compiler has caught a uniqueness violation. The incompatible type tries to figure out which class already used the name, using the Extract<T, U> utility type.

The registerThing() function takes the registry object as its first argument, the name property of the new class instances as its second argument, and the newThing class to be registered as its third argument. It is generic in R, the mapping of class names to their instance types, and T the instance type of the new class. The types of registry and name are straightforward mappings and indexings into R and T. The type of newThing depends on ValidateUniqueThing<R[keyof R], T> (where R[keyof R] is the union of already-registered IThing instance types) in such a way that if newThing is valid, then the type matches it (it just becomes new () => T), but if it is not valid, then the type doesn't match (it becomes new () => X where X is the incompatible type from ValidateUniqueThing above) and you get an error message.

And because registerThing() is an assertion function, its return type asserts registry is ... will cause the registry object's type to narrow after the function is called. The type it narrows to is just the existing registry object type with a new property added corresponding to the newThing argument.


Okay, let's see it in action.

First we make an empty registry object:

const _Things = {};
// const _Things: {}

I prefaced that name with _ because once it's all done we will copy it to a new const so that we are sure that it's ready for use everywhere. Let's register a class:

registerThing(_Things, "ThingOne", class ThingOne {
    readonly name = "ThingOne";
    readonly prop = "Prop";
    generate() {
        return this.name   " "   this.prop
    }
}); // okay

_Things;
/* const _Things: {
    ThingOne: new () => ThingOne;
} */

That succeeded, and now we can see that _Things's type has been changed to reflect the addition. Let's do that again:

registerThing(_Things, "ThingTwo", class ThingTwo {
    readonly name = "ThingTwo";
    readonly prop = "PropTwo";
    generate() {
        return this.name   " "   this.prop
    }
}); // okay

_Things;
/* const _Things: {
    ThingOne: new () => ThingOne;
    ThingTwo: new () => ThingTwo;
} */

Still looks good. Okay, now let's make a mistake:

registerThing(_Things, "ThingThree", class ThingThree { // error!
    // '[Error, "Conflicting ", "prop", "Property with class named ", "ThingTwo"]
    readonly name = "ThingThree";
    readonly prop = "PropTwo";
    generate() {
        return "wha"
    }
});

Uh oh, we got an error. If you squint at the error message, it says that prop conflicts with ThingTwo. Let's fix that:

registerThing(_Things, "ThingThree", class ThingThree {
    readonly name = "ThingThree";
    readonly prop = "PropThree";
    generate() {
        return "wha"
    }
}); // okay

And let's make another mistake:

registerThing(_Things, "ThingFour", class ThingFour { // error!
    // [Error, "Make", "name", "readonly PLEASE"]
    name = "ThingFour";
    readonly prop = "PropFour";
    generate() {
        return ""
    }
});

Uh oh, we got an error. Squinting reveals that name is not readonly and therefore is of type string instead of "ThingFour". Let's fix that:

registerThing(_Things, "ThingFour", class ThingFour {    
    readonly name = "ThingFour";
    readonly prop = "PropFour";
    generate() {
        return ""
    }
}); // okay

Great. Now we copy the completed _Things to Things:

const Things = _Things;
/*
const Things: {
    ThingOne: new () => ThingOne;
    ThingTwo: new () => ThingTwo;
    ThingThree: new () => ThingThree;
    ThingFour: new () => ThingFour;
}*/

which has all the props we want. And to use the classes, we just index into Things:

function foo() {
    const thing2 = new Things.ThingTwo();
    // const thing2: ThingTwo
    console.log(thing2.generate())
}

So, that works! The reason why I copied _Things to Things is that, like all control flow analysis effects, the narrowing done in assertion functions does not persist across function boundaries. So inside foo(), you'd find that _Things would look completely empty:

function foo() {
    const thing2 = new _Things.ThingTwo(); // error!
    // Property 'ThingTwo' does not exist on type '{}'.
}

That's unfortunate, but to prevent that the compiler would have to do a lot of extra effort to try to figure out when foo() was called with respect to when _Things was configured, and that's not feasible (see microsoft/TypeScript#9998 for a discussion about the general issue). It's easy enough to get to a place where the compiler knows the narrowed type is valid, and then copy to a const whose type is the same no matter where you use it.


So, hooray, that whole thing works. It's complicated, and maybe too complicated to be useful to you. Possibly some other approach would be more to your liking, but everything I can think of (including this approach) will involve some kind of circularity, redundancy, ugliness, or "non-locality" of errors.

By that I mean that you will catch uniqueness violations, but the error message will not be close to the location where the violation occurred. For example, if you built some kind of manually maintained union of all classes (which is redundant) and then wrote a type that probed it for uniqueness violations, the error would be somewhere near that type, and not necessarily close to the class with the problem.

So I think this will ultimately come down to a matter of taste and applicability to use cases, as is often the situation with workarounds.

Playground link to code

  • Related