typescripttypescript-generics

Restriction of allowed keys in a generic TypeScript class


I'm struggling on getting the typing for a generic class to work, and I was hoping that someone here has an idea how to implement this.

type ExampleEntries = "foo" | "bar" | "baz"

class Example<
   BASE_SHAPE extends {
      [K in ExampleEntries]: number;
   },
> {
   constructor(values: BASE_SHAPE) {}
   
   update(key: keyof BASE_SHAPE, value: number){}
}

const example = new Example<ExampleEntries>({foo: 0, bar: 1}); // Only values from `ExampleEntries` should be allowed
const errorExample = new Example<ExampleEntries>({foo: 0, bar: 1, fuz: 2}); // This should not work since `fuz` is not part of `ExampleEntries`

example.update("foo", 2) // This should work
example.update("baz", 2) // This should not work since the key was not a part of constructor argument

So basically I want to limit the possible keys for the constructor parameter while only allowing the keys that actually were passed to the constructor in the update method.

Is that even possible?


Solution

  • I find this form of Example to be a bit more functional:

    class Example<E extends ExampleEntries> {
    
        constructor(
            values: { [K in E]?: number }
        ) { }
    
        update(
            key: E,
            value: number
        ) { }
    
    }
    

    However, ultimately, this behavior is not possible:

    const example = new Example<ExampleEntries>({foo: 0, bar: 1});
    // ...
    example.update("baz", 2) // This should not work since the key was not a part of constructor argument
    

    Since baz is a member of ExampleEntries, this call to update is allowed. The type parameter on Example "pre-exists" its constructor arguments, so the type parameter cannot be made stricter based on the value passed to the constructor. You would need to create a type narrower than ExampleEntries.

    const example = new Example<"foo" | "bar">({foo: 0, bar: 1});
    example.update("foo", 3); // compiles
    example.update("baz", 9); // does not compile
    

    Thanks to type inference, we can omit the <...> and get the same result:

    const example = new Example({foo: 0, bar: 1});
    example.update("foo", 3); // compiles
    example.update("baz", 9); // does not compile
    

    It's also worth noting that this problem arises because you allow the object passed to the constructor to not contain keys which are part of the type union in the type parameter. If you remove the ? in the first block of code, the TypeScript compiler would require that foo, bar AND baz are passed to the constructor, obsoleting the 2nd snippet and technically solving the issue.