Pierre
Pierre

Reputation: 6172

TypeScript property decorator that takes a parameter of the decorated property type

I am trying to write a Default decorator, so that this would work:

class A {
  @Default(() => new Date())
  test: Date;
}

I am trying to achieve strong typing, so that the decorator parameter is either (1) of the decorated property type, or (2) a parameterless function returning a value of the decorated property type.

I tried the following:

type DefaultParam<T> = T | (() => T);

export function Default<K extends string, T extends Record<K, V>, V>(param: DefaultParam<V>): (t: T, p: K) => void {
  return (target: T, propertyKey: K) => {
    // Probably irrelevant
  };
}

This, however, fails with:

Argument of type 'A' is not assignable to parameter of type 'Record<string, Date>'.
  Index signature is missing in type 'A'.

Specifying the type parameters, however, works as expected:

class A {
  @Default<'test', A, Date>(() => new Date())
  test: Date;
}

Is there a way to write the decorator so that inference works as expected, to avoid explicitly specifying the parameter in the Default call?

Upvotes: 1

Views: 540

Answers (1)

jcalz
jcalz

Reputation: 328262

I'd say the main problem here is that the curried decorator function can't infer a specification for the K type parameter (or the T type parameter) when you use it. If you have a curried generic function with a type like:

declare const curryBad: <T, U>(t: T) => (u: U) => [T, U]

the compiler will try to infer T and U when you call it. Let's say you call curryBad(1). The 1 value causes T to be inferred as number. But there's no value of type U here, and thus type inference fails for U and it becomes unknown. The result of curryBad(1) is therefore (u: unknown) => [number, unknown]:

const bad = curryBad(1)(""); // [number, unknown]

While it's not inconceivable that a compiler might theoretically defer inferring U until the returned function is called, that's not the way it works in practice. Instead, you can just write the function's signature that way to begin with: don't declare U as a parameter of the initial function; declare it as a parameter of the returned function instead:

declare const curryGood: <T>(t: T) => <U>(u: U) => [T, U]

Now calling curryGood(1) will return a value of type <U>(u: U) => [number, U], which is probably what you want:

const good = curryGood(1)(""); // [number, string]

With that in mind, I'd suggest moving your K parameter. Also, I'm not sure you really need T to be its own generic type; Record<K, V> might be good enough. But if you find you do need it, it should also be moved along with K:

export function Default<V>(
  param: DefaultParam<V>): <K extends PropertyKey>(t: Record<K, V>, p: K) => void {
  return <K extends PropertyKey>(target: Record<K, V>, propertyKey: K) => {    
  };
}

And now your decorator should hopefully work as desired:

class Good {
  @Default(() => new Date()) // no error
  test!: Date;
}

class Bad {
  @Default(123) // error! Date is not number
  test!: Date; 
}

Okay, hope that helps; good luck!

Playground link to code

Upvotes: 2

Related Questions