Yanis
Yanis

Reputation: 4997

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:

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

Upvotes: 2

Views: 2767

Answers (2)

jcalz
jcalz

Reputation: 328057

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

Upvotes: 2

user12251171
user12251171

Reputation:

It seems this is not possible to do, in the TypeScript Deep Dive book there's a section on exactly this. Although you can declare a type you can't actually create an object with it in TS:

// No error on this type declaration:
type Declarations = {
    [key: string]: string;
} & {
    onMember: number;
    onCollection: number;
}

// Error does appear here indicating type `number` is not assignable to type `string`.
const declarations: Declarations = {
    onMember: 0,
    onCollection: 0,
    other: 'Is a string'
}

TypeScript Playground link.

Upvotes: 1

Related Questions