My app allows users to create questionnaires for other people to fill out. While creating a form, users are allowed to select between 5 different categories of questions, and each one maps to a specific type of the response field. Both a newly created questionnaire and one that has been submitted share the same Question model, so be default, the response has null value/type. And as long as a particular question isn't required to answer, the response may remain as null. Here is the current Question model.

export interface Question {
    prompt: string
    category: QuestionCategory
    options: string[]
    isRequired: boolean
    response: AnyQuestionResponse
}

export type QuestionCategory = 'TEXT' | 'PARAGRAPH' | 'DATETIME' | 'SINGLE_CHOICE' | 'MULTIPLE_CHOICE'
export type AnyQuestionResponse = string | Date | number | number[] | null

And here is how the category corresponds with the response type

  • TEXT -> string
  • PARAGRAPH -> string
  • DATETIME -> Date
  • SINGLE_CHOICE -> number
  • MULTIPLE_CHOICE -> number[]

However, this isn't the whole story. Like I said above, a question's isRequired field affects whether a response can be null. So there are two independent fields driving the type of another. And taking it even further, if the category is SINGLE_CHOICE or MULTIPLE_CHOICE, the allowable response value(s) should not exceed the option's field length (since it is just the index value of whatever option in the array a user selects).

Ideally, I'd like to have something like a BaseQuestion type of which specific question types extend. I'm not too experienced with Typescript so in pseudocode I imagine it would be something like this.

export type QuestionCategory = 'TEXT' | 'PARAGRAPH' | ... // Should have an associated child `ResponseType` type
export type IsRequiredType = boolean // Should have an associated child `NullIfFalse` type

export interface BaseQuestion<T=QuestionCategory, R=IsRequiredType> {
    prompt: string
    category: QuestionCategory
    options: string[]
    isRequired: R
    response: T.ResponseType | R.NullIfFalse // T dictates what its non-null type is while R determines whether it can be null
}

export interface TextQuestion<R=IsRequiredType> extends BaseQuestion<'TEXT', R> { }
// ... ParagraphQuestion, DateQuestion, SingleQuestion ...
export interface MultiChoiceQuestion<R=IsRequiredType> extends BaseQuestion<'MULTIPLE_CHOICE', R> { }

// .................. Example models ..........................

const textQuestion: TextQuestion = {
    prompt: "Example",
    category: "TEXT", // Response must be string
    options: [],
    isRequired: false, // Null response allowed
    response: 'asdf' // <--- OK
}
const requiredTextQuestion: TextQuestion = {
    prompt: "Example",
    category: "TEXT", // Response must be string
    options: [],
    isRequired: true, // Response must not be null
    response: null // <--- ERROR WOULD BE THROWN HERE
}
const singleChoiceQuestion: SingleChoiceQuestion = {
    prompt: "Example",
    category: "SINGLE_CHOICE", // Response must be an integer number
    options: ["A", "B", "C"], // Response must be between 0 and 2
    isRequired: false, // Null response allowed
    response: 3 // <--- ERROR THROWN HERE
}

If anybody has ideas/thoughts on how to implement I'd be more than grateful to hear about. I have a feeling this can be more trouble setting up than it's worth so I don't think I'll implement this anyways, but I do see this more of as an exercise to learn data structuring strategies, if anything. Plus I think it's fun to find optimized solutions. Though I can see this directly helping out with my type validating algorithms if/when more QuestionCategory question types are introduced in the future. They currently work and are very thorough, yet very loose and unorganized. This is what I imagine a cleaner solution would look like for validation:

type AnyQuestion = TextQuestion | ... | MultiChoiceQuestion

function validate(question: AnyQuestion): boolean { 
    switch (question.category) { 
        case 'TEXT':
            return validateTextQuestion(question as TextQuestion)
        case 'PARAGRAPH':
            return validateParagraphQuestion(question as ParagraphQuestion)
        case 'DATETIME':
            return validateDateQuestion(question as DateQuestion)
        // ...
    }

    // Validation requirements are more constrained in accordance with what the specific question type allows ...
    function validateTextQuestion(question: TextQuestion): boolean { ... }
    function validateParagraphQuestion(question: ParagraphQuestion): boolean { ... }
    function validateDateQuestion(question: DateQuestion): boolean { ... }
    // ....
}

Compared to a current snippet of my working version:

export class SubmissionResponsesValidator { 

    // Checks that all submission's responses field satisfies the following requirements:
    // - Is an array of valid Question objects
    // - A response is provided if the question is required to answer
    // - The response type matches with the question's corresponding category
    // - NOTE: An empty array is possible (if all questions unrequired and all responses null)
    public static validate(questions: Question[]): string | true { 
        // 1. Check is array
        if (!tc.isArray(questions)) { 
            return UIError.somethingWentWrong
        }

        // 2. Check each item is Question type
        let allAreQuestions = questions.every(question => this.isQuestion(question))
        if (!allAreQuestions) { 
            return UIError.somethingWentWrong
        }

        // 3. Check that each question's response is Provided if required to respond
        let allResponsesProvided = questions.every(question => this.responseIsProvidedIfRequired(question))
        if (!allResponsesProvided) { 
            return UIError.responsesInvalid
        }
        // 4. Check that each question's response type matches the category
        let allResponseMatchCategory = questions.every(question => this.responseMatchesCategory(question))
        if (!allResponseMatchCategory) { 
            return UIError.somethingWentWrong
        }

        return true
    }

    // 2. Checks each item is Question type
    private static isQuestion(question: Question): boolean { 
        let list = ['TEXT', 'PARAGRAPH', 'DATETIME', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE']
        if (!tc.isString(question.prompt) ||
            !list.includes(question.category) ||
            !tc.isArrayOfString(question.options) ||
            !tc.isBool(question.isRequired) ||
            !this.isResponseType(question.response)) { 
            return false
        }
        return true
    }

    // 2. ...
    // Checks that a value is of Question response type
    // Must be string / number / date / number[] / null
    private static isResponseType(response: AnyQuestionResponse): boolean { 
        if (tc.isString(response) ||
            tc.isNumber(response) ||
            tc.isDate(response) ||
            tc.isArrayOfNumbers(response) ||
            tc.isNull(response)) { 
            return true
        }
        return false
    }

    // 3a. Check that question response is provided if required to respond to
    // Note this does not check for type since 2 already does that
    private static responseIsProvidedIfRequired(question: Question): boolean { 
        if (question.isRequired) { 
            return tc.isDefined(question.response)
        }
        return true
    }

    // 3b. Check that question's response matches with its category
    private static responseMatchesCategory(question: Question): boolean { 
        // No need to check for required 
        if (!question.response) { 
            return true
        }
        
        switch (question.category) { 
            case 'SINGLE_CHOICE': 
                return tc.isNumber(question.response)
            case 'MULTIPLE_CHOICE': 
                return tc.isArrayOfNumbers(question.response)
            case 'TEXT': 
                return tc.isString(question.response)
            case 'PARAGRAPH': 
                return tc.isString(question.response)
            case 'DATETIME': 
                return tc.isDate(question.response)
            
            default: 
                return false
        }
    }
}
0

There are 0 best solutions below