Jordan
Jordan

Reputation: 5445

force typescript class method to be unary method with an argument of a specific type

For fun I wanted to see if I could come up with a way, using just types, that would force all functions in a class to be a unary function that takes any object so long as that object conforms to a specified interface (in this case I wanted to make sure a ContextObj was provided as the argument)

I took a stab at it like this:

interface BaseContext {}
interface SomeContext { foo: 'lish'; };
interface ContextObj<T> { context: T }

// a unary function that takes an argument that conforms to the ContextObj interface
// and returns a value of type U
type ContextFn<T, U> = (x: ContextObj<T>)  => U;

// a type to represent any function
type AnyFunction = (...args: any[]) => any;

// we use mapped types and conditional types here to determine if
// T[K], the value of the property K on type T, is a function
// if it's not a function we return the T[K] unaltered
// if it is a function we make sure it conforms to our ContextFn type
// otherwise we return never which I was hoping would result in an error
type ContextService<T, U extends BaseContext> = {
  [K in keyof T]: T[K] extends AnyFunction
  ? T[K] extends ContextFn<U, ReturnType<T[K]>>
    ? T[K]
    : never
  : T[K]
};

class BarService implements ContextService<BarService, SomeContext> {
  test:string = 'test';
  // expected error: not assignable to type never
  updateBar(): string {
    return '';
  }
}

It turns out this did not work as expected as it is a limit (or feature?) of TypeScript: https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-with-fewer-parameters-assignable-to-functions-that-take-more-parameters

I'm curious if there's a way to do something like this (force method arguments to match to specific type signatures) at the class or interface level? Can it be done at the individual method level via a decorator (granted at that point you might as well just use a proper type signature).

Just testing out the limits :)

Upvotes: 1

Views: 787

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250156

You just need to add another condition for the length of the parameters. You can extract the type of the parameters as a tuple using Parameters. The tuple will have a length property that is of a number literal type, so you can check that the length extends 1:

type ContextService<T, U extends BaseContext> = {
    [K in keyof T]: T[K] extends AnyFunction ?
        T[K] extends ContextFn<U, ReturnType<T[K]>> ?
            Parameters<T[K]>["length"] extends 1 ? T[K]
        : never : never
    : T[K]
};

class BarService implements ContextService<BarService, SomeContext> {
    test: string = 'test';
    // error: not assignable to type never
    updateBar(): string {
        return '';
    }
    updateBar2(p: ContextObj<SomeContext>): string { // ok
        return '';
    }
}

Might be useful to give a hint to the user as to the problem, athough custom errors are not supported, this will be pretty close:

type ContextService<T, U extends BaseContext> = {
    [K in keyof T]: T[K] extends AnyFunction ?
        T[K] extends ContextFn<U, ReturnType<T[K]>> ?
            Parameters<T[K]>["length"] extends 1 ? T[K]
        : ["Method must have exactly one parameter of type ", ContextObj<U>, "Found Parameters:", Parameters<T[K]>] 
        : ["Parameters types not macthed, expected  [", ContextObj<U>, "] Found Parameters:", Parameters<T[K]>]
    : T[K]
};

class BarService implements ContextService<BarService, SomeContext> {
    test: string = 'test';
    // error: not assignable to type never
    updateBar(): string {// Type '() => string' is missing the following properties from type '["Method must have exactly one parameter of type ", ContextObj<SomeContext>, "Found Parameters:", []]'
        return '';
    }
    updateBar2(p: ContextObj<SomeContext>): string {
        return '';
    }

    updateBar3(p: SomeContext): string { // Type '(p: SomeContext) => string' is missing the following properties from type '["Parameters types not macthed, expected  [", ContextObj<SomeContext>, "] Found Parameters:", [SomeContext]]'
        return '';
    }
}

Edit

Answering comment question: Can this be done with decorators ?

Answer is yes, we can capture as type parameters the class the decorator is being applied to as well as the key it is applied to and use the same logic as we did for a property key previously. Just that this time we attach the error to the key passed into the decorator:

type Check<TSig extends (...a: any[]) => any, T, K extends keyof T> = 
    T[K] extends (...a: any[]) => any ?
    T[K] extends TSig ?
            Parameters<T[K]>["length"] extends 1 ? unknown
        : ["Method must have exactly one parameter of type ", Parameters<TSig>, "Found Parameters:", Parameters<T[K]>] 
        : ["Parameters types not macthed, expected  [", Parameters<TSig>, "] Found Parameters:", Parameters<T[K]>]
    : unknown
function ensureSignatire<TSig extends (...a: any[]) => any>() {
    return function <TTarget, TKey extends keyof TTarget>(target: TTarget, key: TKey & Check<TSig, TTarget, TKey>) {

    }
}

class BarService {
    test: string = 'test';

    @ensureSignatire<ContextFn<SomeContext, any>>() // Type '"updateBar"' is not assignable to type '["Method must have exactly one parameter of type ", [ContextObj<SomeContext>], "Found Parameters:", []]'.
    updateBar(): string {
        return '';
    }

    @ensureSignatire<ContextFn<SomeContext, any>>() //ok
    updateBar2(p: ContextObj<SomeContext>): string {
        return '';
    }
    @ensureSignatire<ContextFn<SomeContext, any>>() //  Type '"updateBar3"' is not assignable to type '["Parameters types not macthed, expected  [", [ContextObj<SomeContext>], "] Found Parameters:", [SomeContext]]'
    updateBar3(p: SomeContext): string { 
        return '';
    }
}

Upvotes: 4

Related Questions