Search code examples
typescript

Explicitly typing an object with TypeScript


I'm working on converting my little library from JavaScript to TypeScript, and I have a function there

function create(declarations: Declarations) {

Now the declaration is an object the keys of which can be of 2 types:

  • If the keys are one of those strings: onMemeber / onCollection, then the value should be a number
  • For any other string keys the value should be a string

Is that possible to enforce with TypeScript? How should I define my Declarations interface?


Solution

  • There is no concrete type in TypeScript that represents your Declarations shape.

    I'd call the general idea a "default property" type. (GitHub issue asking for this is microsoft/TypeScript#17867) You want specific properties to be of one type, and then any others to "default" to some other incompatible type. It's like an index signature without the constraint that all properties must be assignable to it.

    ( Just to be clear, an index signature cannot be used:

    type BadDeclarations = {
        onMember: number, // error! number not assignable to string
        onCollection: number, // error! number not assignable to string
        [k: string]: string
    };
    

    The index signature [k: string]: string means every property must be assignable to string, even onMember and onCollection. To make an index signature that actually works, you'd need to widen the property type from string to string | number, which probably doesn't work for you. )

    There were some pull requests that would have made this possible, but it doesn't look like they are going to be part of the language any time soon.

    Often in TypeScript if there's no concrete type that works you can use a generic type which is constrained in some way. Here's how I'd make Declarations generic:

    type Declarations<T> = {
        [K in keyof T]: K extends 'onMember' | 'onCollection' ? number : string
    };
    

    And here's the signature for create()L

    function create<T extends Declarations<T>>(declarations: T) {
    }
    

    You can see that the declarations parameter is of type T, which is constrained to Declarations<T>. This self-referential constraint ensures that for every property K of declarations, it will be of type K extends 'onMember' | 'onCollection' ? number : string, a conditional type that is a fairly straightforward translation of your desired shape.

    Let's see if it works:

    create({
        onCollection: 1,
        onMember: 2,
        randomOtherThing: "hey"
    }); // okay
    
    create({
        onCollection: "oops", // error, string is not assignable to number
        onMember: 2,
        otherKey: "hey",
        somethingBad: 123, // error! number is not assignable to string
    })
    

    That looks reasonable to me.


    Of course, using a generic type isn't without some annoyances; suddenly every value or function that you wanted to use Declarations with will need to be generic now. So you can't do const foo: Declarations = {...}. You'd need const foo: Declarations<{onCollection: number, foo: string}> = {onCollection: 1, foo: ""} instead. That's obnoxious enough that you'd likely want to use a helper function like to allow such types to be inferred for you instead of manually annotated:

    // helper function
    const asDeclarations = <T extends Declarations<T>>(d: T): Declarations<T> => d;
    
    const foo = asDeclarations({ onCollection: 1, foo: "a" });
    /* const foo: Declarations<{
        onCollection: number;
        foo: string;
    }>*/
    

    Okay, hope that helps; good luck!

    Link to code