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
}
}
}