Search code examples
javascripttypescriptmethod-chaining

TS: How to define return typing of reusable chained methods


I have the following code:

const mixed = {
  validations: [] as any[],
  formattings: [] as any[],
  exceptions: [] as any[],
  required(message?: string) {
    this.validations.push({
      name: 'required',
      message: message || config.messages.mixed.required,
      test: (value: string) => {
        return value && value.trim() ? true : false
      },
    })

    return this
  },
  // other methods that will be reusable
}

const string = () => ({
  ...mixed,
  maxWords(maxWords: number, message?: string) {
    type Params = { maxWords: number }

    this.validations.push({
      name: 'maxWords',
      params: { maxWords },
      message: message || config.messages.string.maxWords,
      test: (value: string, { maxWords }: Params) => {
        const wordCount = value.trim().split(' ').length

        return wordCount <= maxWords
      },
    })

    return this
  },

  trim() {
    this.formattings.push({
      name: 'trim',
      format: (value: string) => {
        return value.trim()
      },
    })

    return this
  },
})

const number = () => ({
  ...mixed,
  positive(message?: string) {
    this.validations.push({
      name: 'positive',
      message: message || config.messages.string.maxWords,
      test: (value: string) => {
        // make a validation
      },
    })

    return this
  },
})

const schema = {
  string() {
    return string()
  },
  number() {
    return number()
  },
  // file, date, etc..
}

const form = {
  name: schema.string().required().trim().maxWords(3),
  age: schema.number().required().positive(),
}

Everything works perfectly in execution, the problem is that I am trying to build a form validation library and I need intellisense working on all methods.

here's the problem

Here's TS Playground so you can test it in real time.

the problem is in the typing of the returns of each function


Solution

  • You can use required(message?: string): this if you declare the method type in an interface or class. Stripped down example:

    interface Validation<T> {
      message: string
      test: (value: T) => boolean
    }
    
    interface FormField<T> {
      validations: Validation<T>[];
      required(message: string): this;
    }
    
    const mixed: FormField<unknown> = {
      validations: [],
      required(message) {
        this.validations.push({
          message,
          test(value: string) {
            return value.trim()
          }
        })
        return this
      }
    }
    
    interface StringField extends FormField<string> {
      maxWords(maxWords: number, message: string): this;
    }
    const string = (): StringField => ({
      ...mixed as StringField, // this is a bit ugly, would work better if mixed() was a function or superclass
      maxWords(maxWords, message) {
        this.validations.push({
          message,
          test: value => value.trim().split(' ').length <= maxWords,
        })
        return this
      }
    })
    
    interface NumberField extends FormField<number> {
      positive(message: string): this;
    }
    
    const number = (): NumberField => ({
      ...mixed as NumberField,
      positive(message) {
        this.validations.push({
          message,
          test: value => value > 0,
        })
    
        return this
      },
    })
    
    const form = {
      name: string().required('missing name').maxWords(3, 'too few words'),
      age: number().required('missing age').positive('must be born already'),
    }
    

    (TypeScript playground)