In order for the compiler to keep track of the string literal types corresponding to valid edge ids, we will need to make Package generic in the union of those types, and all the types and interfaces that hold such ids should be generic as well.
Something like this:
interface IEdge<K extends string = string> {
id: K
}
interface IModule<K extends string = string> {
id: string
data: {
inputEdges: readonly IEdge<K>[]
}
}
Here I've added the generic type parameter K to both IEdge and IModule. If you don't specify them they will default to string like your version. Also, when you create sampleModule the compiler will infer just string for those ids unless you give it a hint that it should pay closer attention, such as with a const assertion:
const sampleModule = {
id: "sampleModule",
data: {
inputEdges: [
{
id: "sampleEdge"
},
{
id: "anotherEdge"
}
]
}
} as const;
That as const causes the compiler to infer sampleModule as this type:
/* const sampleModule: {
readonly id: "sampleModule";
readonly data: {
readonly inputEdges: readonly [{
readonly id: "sampleEdge";
}, {
readonly id: "anotherEdge";
}];
};
} */
So now the compiler definitely knows that "sampleEdge" and "anotherEdge" are valid edge ids. Also note that inputEdges has been inferred as a readonly array type, which is technically wider than a read-write array. That's why I widened the type in IModule<K> to readonly IEdge<K>[]. That probably won't matter unless you need to start modifying that array after the fact (but I hope you don't, since that could change which edge ids are valid).
To make this easy, I'm going to replace your init() method with a constructor argument. That way, the type of your package instance can know about the edge ids from the moment it's created. Otherwise we'd have to try to narrow the type when you call init(), which is hard to do properly. You can technically do it by making init() an assertion method, but it's not fun. Unless someone needs to have a package instance sit around before it's initialized, we should have the initialization done in the constructor, which is more conventional anyway.
Here it is:
class Package<K extends string> {
constructor(public module: IModule<K>) { }
recieveOnEdge(
edgeId: K,
callback: any
) {
console.log(edgeId, callback)
}
}
So you can see that receiveOnEdge() only takes an edgeId of type K. Let's test it out:
const packageInstance = new Package(sampleModule);
// const packageInstance: Package<"sampleEdge" | "anotherEdge">
So the compiler infers that packageInstance is of type Package<"sampleEdge" | "anotherEdge">, leading to the behavior you wanted with receiveOnEdge():
packageInstance.recieveOnEdge("sampleEdge", "doesNotMatter"); // okay
packageInstance.recieveOnEdge("badEdge", "doesNotMatter"); // error
Playground link to code
init()step. Why are you usinginithere? What's the use of having aPackage()that can sit around in an uninitialized state?