8

How do you type an object that can have both a few declared optional properties, e.g.:

{ 
    hello?: string, 
    moo?: boolean 
}

as well as custom properties (that must be functions), e.g.:

    [custom: string]: (v?: any) => boolean

This is what I'd like to see for example:

const myBasic: Example = {moo: false}
// -> ✅ Valid! Using known keys

const myValid: Example = {hello: 'world', customYo: () => true}
// -> ✅ Valid! "customYo" is a function returning a bool. Good job!

const myInvalid: Example = {hello: 'world', customYo: 'yo!'}
// -> ☠️ Invalid! "customYo" must be a function returning a boolean

Trying to add an index signature to an interface with known keys (i.e. hello?: string, moo?: boolean) requires all keys to be subsets of the index signature type (in this case, a function returning a boolean). This obviously fails.

papercowboy
  • 3,369
  • 2
  • 28
  • 32
  • Does this answer your question? [How to define Typescript type as a dictionary of strings but with one numeric "id" property](https://stackoverflow.com/questions/61431397/how-to-define-typescript-type-as-a-dictionary-of-strings-but-with-one-numeric-i) – kaya3 Dec 15 '21 at 14:27

4 Answers4

10

The question accepted by the owner (until now) is incorrect.

Here's how you can do it:

You need to make the index signature a union type of all the types that can be contained in the interface:

interface IExample {
    hello?: string;
    moo?: boolean;
    [custom: string]: string | boolean | YourFunctionType;
}

interface YourFunctionType {
    (v?: any): boolean;
}

Please note that I've extracted your function type into a separate interface to improve readability.

Implications:

This means, that the explicitly defined properties are well supported by TS:

const test: IExample = <IExample>{};
test.hello.slice(2); // using a string method on a string --> OK
const isHello = test.hello === true; // ERROR (as expected): === cannot be applied to types string and boolean
const isMoo2 = test.moo === true; // OK

However all properties from the index signature now need to be checked using type guards which adds a little bit of a runtime overhead:

test.callSomething(); // ERROR: type 'string | boolean | YourFunctionType' has no compatible call signatures
if (typeof test.callSomething === 'function') { // alternatively you can use a user defined type guard, like Lodash's _.isFunction() which looks a little bit nicer
    test.callSomething(); // OK
}

On the other hand: the runtime overhead is necessary because it might be that test is accessed like this:

const propertyName: string = 'moo';
test[propertyName](); // ERROR: resolves to a boolean at runtime, not a function ...

// ... so to be sure that an arbitrary propertyName can really be called we need to check:
const propertyName2: string = 'arbitraryPropertyName';
const maybeFunction = test[propertyName2];
if (typeof maybeFunction === 'function') {
    maybeFunction(); // OK
}
T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
NicBright
  • 7,289
  • 5
  • 18
  • 19
  • This breaks type inference when all the properties are functions though? i.e. type inference of the function parameters – Luke Ramsden May 13 '20 at 08:33
  • @Luke Ramsden: do you have an example? – NicBright Jun 09 '20 at 07:28
  • Is there some configuration setting that makes this work as shown? Because [it doesn't work in the playground](https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgJIFEAecC2AHAGxQG8AoZC5ACwgIIHsB+ALmQGcwpQBzAbnMo56TVgCNhROCH6VkAbQQBXDvRysOXENwC66zj2QAfZOPqSQR5AE16iqADFFIBGGD0QAFQCeeCPwC+pKSgkLCIKDZ2js6u7t6+yGSyABQAbizIUl4AlGISEFIBQA) as shown, the named properties being optional would require that `undefined` be in the index signature type. Or am I missing something? (It's entirely likely. :-) ) – T.J. Crowder Dec 17 '21 at 09:14
  • You're right. Like you've suggested, you can either (1) add undefined to the type of the index signature. Or you can (2) use the new exactOptionalPropertyTypes compiler flag to make it work. – NicBright Dec 18 '21 at 15:02
6

This is not possible, by design https://basarat.gitbooks.io/typescript/docs/types/index-signatures.html

As soon as you have a string index signature, all explicit members must also conform to that index signature. This is to provide safety so that any string access gives the same result.

The only way to get around it is to exploit that each interface can have 2 separate index signatures, one for string and number

In you example hello and moo make the string index unusable, but you can hijack the number index for the custom methods

interface IExample {
  hello?: string
  moo?: boolean
  [custom: number]: (v?: any) => boolean
}

const myBasic: IExample = {moo: false}
// -> ✅ Valid! Using known keys

const myValid: IExample = {hello: 'world', 2: () => true}
// -> ✅ Valid! "customYo" is a function returning a bool. Good job!

const myInvalid: IExample = {hello: 'world', 2: 'yo!'}
// -> ☠️ Invalid! "customYo" must be a function returning a boolean

This works but is hardly an acceptable interface as would lead to unintuitive functions and you would have to call them by array notation

myValid.7() // Cannot invoke an expression whose type lacks a call signature. Type 'Number' has no compatible call signatures.
myValid[2]() // works (but ewwwww what is this!!!)
// could alias to more readable locals later but still ewwwwww!!! 
const myCustomFunc = myValid[2]
myCustomFunc() // true

This also has the caveat that the type returned from a numeric indexer must be a subtype of the type returned from the string indexer. This is because when indexing with a number, javascript will convert the number to a string before indexing into an object

In this case you have no explicit string indexer, so the string index type is the default any which the numeric indexer type can conform to

IMPORTANT This is just for the science, I don't recommend this as a real life approach!

CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
alechill
  • 4,274
  • 18
  • 23
  • Great reply - and this conforms with what I've found. I've been forced to go with `[custom: string]: any` thus losing type safety. Oh well! ⚡️ – papercowboy Dec 01 '17 at 14:00
1

One quite simple way to achieve what @NicBright's answer proposes is using a 'higher order type', which would extend your already defined types with whatever index signatures you would like, e.g.:

type MyOptionalProperties = { 
    hello?: string, 
    moo?: boolean 
};

type WithCustomFunctions<T> = {
  [custom: number]: ((v?: any) => boolean) | T[keyof T];
} & T;

type MyOptionalPropertiesWithCustomFunctions = WithCustomFunctions<MyOptionalProperties>;

Nice thing about this approach is the possibility to apply your higher order type to other structures (reusability of WithCustomFunctions).

Bad thing is all implications from his solution still apply.

RochaLBR
  • 68
  • 1
  • 7
0

Late to the party, but you can use generics and make some conditionals that work

 type Example<T extends string> = {
     [key in T]: key extends "hello"
       ? string
       : key extends "moo"
       ? boolean
       : (v?: any) => boolean
 }

This comes with a few caveats:

  • This only really works in contexts where TypeScript can automatically infer the generic type:

    // This works
    function checkExample<T extends string>(arg: Example<T>): Example<T> {
        return arg;
    }
    
    // We can use it to automatically infer the generic type
    // Here myExample has the correct type Example<...>
    const myExample = checkExample({hello: 'world', customYo: () => true});
    
    // This does not work, unless you're willing to type a union of all keys
    const myExample: Example = {hello: 'world', customYo: () => true};
    
  • This makes it so that all keys are optional. If you need to make a key required you will have to intersect with another type:

    type Example<T extends string> = {
        [key in T]: key extends "hello"
          ? string
          : key extends "moo" // We still need this here
          ? boolean
          : (v?: any) => boolean
    } & {
        moo: boolean // We declare it here as well
    }
    
sdrm
  • 194
  • 1
  • 8