Joe Skeen
Joe Skeen

Reputation: 1787

TypeScript Type Annotation Excluding Primitives

The question: Is there any way to write a type annotation in TypeScript that allows any object literal, but doesn't allow built-in types number, string, boolean, Function or Array?

Why?

I have been working on improving some type definitions on DefinitelyTyped for some libraries I am using on my projects at work. A common pattern I have noticed in many JS libraries is to pass an 'options' object used to configure the library or plugin. In these cases, you will often you will see type definitions looking something like this:

declare module 'myModule' {
    interface IMyModule {
        (opts?: IOptions): NodeJS.ReadWriteStream;
    }
    interface IOptions {
        callback?: Function;
        readonly?: boolean;
    }
}

This allows you to use it like this:

var myModule = require('myModule');
myModule();
myModule({});
myModule({ callback: () => {} });
myModule({ readonly: false });
myModule({ callback: () => {}, readonly: false });

Which all are valid use cases. The trouble is that these type definitions also allow for these non-valid use cases:

myModule('hello');
myModule(false);
myModule(42);
myModule(() => {});
myModule([]);

In many cases, the above calls would result in a runtime error, as the library JS code may try to set default values on the object, or pass the options to another library. Although I have tried many things, I haven't been able to constrain the parameter to accept only objects and not any of those other invalid cases.

It seems that if you have an interface with only optional members (as no one option is required), the compiler will widen acceptable types to accept any.

You can see a TypeScript Playground demonstration of this problem here: http://bit.ly/1Js7tLr

Update: An example of a solution that doesn't work

interface IOptions {
    [name: string]: any;
    callback?: Function;
    readonly?: boolean;
}

This attempt requires an indexing operator to be present on the object, which means that it now complains about numbers, strings, booleans, Functions, and Arrays. But this creates a new problem:

var opts = {};
myModule(opts);

This now fails when it should be a valid scenario. (See this in Playground: http://bit.ly/1MPbxfX)

Upvotes: 0

Views: 1110

Answers (2)

Joe Skeen
Joe Skeen

Reputation: 1787

As of TypeScript 2.2, there is now an object type that does almost exactly what I have described above.

... you can assign anything to the object type except for string, boolean, number, symbol, and, when using strictNullChecks, null and undefined.

Check out the official announcement here: Announcing TypeScript 2.2

Upvotes: 2

Fenton
Fenton

Reputation: 250932

For an interface to have sensible enforcement, it must have at least one mandatory member. Essentially, the interface below enforces nothing:

interface IOptions {
    callback?: Function;
    readonly?: boolean;
}

It is actually equivalent to the "evil interface":

interface IOptions {
}

So you are getting auto-completion, but no real type checking.

Your proposed solution is the way to go...

interface IOptions {
    [name: string]: any;
    callback?: Function;
    readonly?: boolean;
}

In most cases, people tend to construct these options within the function call, but if they really want to do it elsewhere they can use a type assertion:

// If you must
var opts = <IOptions>{};
myModule(opts);

// More typical
myModule({});

// Although... if its empty
myModule();

Disclaimer. A small part of this is just my opinion... but an empty interface is the worst thing you can do with an interface in TypeScript and one that enforces nothing is the second-worst thing.

You could avoid the duplication using your own base interface...

interface Indexed {
    [name: string]: any;
}

Or even...

interface Indexed<T> {
    [name: string]: T;
}

And then use it on all your options interfaces...

interface IOptions extends Indexed {
    callback?: Function;
    readonly?: boolean;
}

Upvotes: 1

Related Questions