Search code examples
typescriptcovariancetypescript-generics

How to express a "read only record" type in TypeScript that is covariant with compatible interfaces?


I need to define a method that serializes objects for storage, but the semantics of it require that it can only work with objects whose fields keys are of string type (because they must match the column's names, so they cannot be numbers).

Using a Record<string, unknown> type won't work for this serializer input, because I am supplying it objects that have specific fields defined (like interface Foo {foo: number, bar: string} and these are not assignable to Record<string, unknown>).

Same thing why we cannot do safely assign a List<Cat> to a List<Animal> if List is mutable, but we can if they are immutable .

So I need to either specify that a generic type parameter's keyof T is a subset of string or have a ReadOnlyRecord<string, unknown> that is covariant with any interface that has string keys.

Suggestions?


Solution

  • You do not need to take variance into account here. The reason why Record<string, unknown> fails is simply due to the utility requiring its type parameter to have an index signature. For the same reason, the method cannot expect to get a type assignable to { [x:string]: unknown } as its only parameter.

    In any case, I do not think you will be able to do that without generic type parameters. Then you simply need to check if a string-only keyed type like { [P in Extract<keyof T, string> : any } is assignable to the passed-in type (the other way around will obviously always pass as your interfaces are subtypes of the former).

    Note that X extends T ? T : never in the parameter is needed for the compiler to be able to infer T from usage while still maintaining the constraint. The only caveat is that you will not be able to catch symbol properties (but will be if their type is unique symbol):

    {
        class Bar {
            public baz<T extends object>(serializable: { [P in Extract<keyof T,string>] : any } extends T ? T : never) { return JSON.stringify(serializable); }
        }
    
        const foo: Foo = { foo: 42, bar: "answer" };
    
        const nope = { [1]: true };
    
        const symb = { [Symbol("ok")]: 24 };
    
        const US: unique symbol = Symbol("unique");
    
        const usymb = { [US]: Infinity };
    
        const bar = new Bar();
        bar.baz(foo); //OK
        bar.baz(nope); //Argument of type '{ 1: boolean; }' is not assignable to parameter of type 'never'
        bar.baz(symb); //OK? Inferred as `{ [x: string]: number; }`
        bar.baz(usymb); //Argument of type '{ [US]: number; }' is not assignable to parameter of type 'never'
    }
    

    Playground