Nick Schmidt
Nick Schmidt

Reputation: 1527

How to deeply infer types using TypeScript?

I am currently struggling with some sort of type inference.

Current State

At the moment, my object looks like this:

const config: Config = {
  calculate({ answers }) {
    answers.questionOne // <-- SHOULD be allowed
    answers.questionUnknown // <-- Should NOT be allowed
  },
  questions: {
    questionOne: {
      title: "Test Title",
      answers: ["answer-1", "answer-2", "answer-3"],
    },
  },
}

For my types, I am having the Config type like this:

// The answers object looks like: { questionKey: answerKey }.
type CalculateCtx = { answers: Record<string, string> }

type Question = {
  title: string;
  answers: string[];
}

type Config = {
  calculate(ctx: CalculateCtx): void;
  questions: Record<string, Question>;
}

Goal State

My goal now, is that I want to be able to type the object in a matter of very strict. I want the calculate function, to only type the answers with the available question keys. So that answers in the calculate function would be typed as { questionOne: "answer-1" | "answer-2" | "answer-3" } and not as { [key: string]: string }.

I know, that this is possible (since libraries like trpc and stitches.js do this stuff), but I find it hard to find out how to properly do this.

Can someone please help me.

Upvotes: 0

Views: 348

Answers (2)

qrsngky
qrsngky

Reputation: 2916

An approach using 'as const'. I added Partial<> just to allow 'unanswered' state (with the side effect of the types having additional | undefined.

const QuestionSettings = {
  questionOne: {
    title: "Title 1",
    answers: ["answer-1a", "answer-1b", "answer-1c"],
  },
  questionTwo: {
    title: "Title 2",
    answers: ["answer-2a", "answer-2b"],
  },
} as const;


type AvailableAnsMap = {
  [qName in keyof typeof QuestionSettings]:
  typeof QuestionSettings[qName] extends { answers: Readonly<unknown[]> } ?
  typeof QuestionSettings[qName]['answers'][number] : never
}

type Config = {
  calculate(ctx: { answers: Partial<AvailableAnsMap> }): void;
  questions: typeof QuestionSettings;
}

const config: Config = {
  calculate({ answers }: { answers: Partial<AvailableAnsMap> }) {
    answers.questionOne //"answer-1a" | "answer-1b" | "answer-1c" | undefined
    answers.questionTwo //"answer-2a" | "answer-2b" | undefined
  },
  questions: QuestionSettings
}

let studentAns1: Partial<AvailableAnsMap> = { questionOne: 'answer-1c' };
let studentAns2: Partial<AvailableAnsMap> = { questionTwo: 'answer-2a' };
let studentAns3: Partial<AvailableAnsMap> = { questionOne: 'answer-1a', questionTwo: 'answer-2a' };

config.calculate({ answers: studentAns1 })

EDIT

Edited to show a way to handle multiple configs without repeating the helper type for every single question settings. The helper types at the top of the second code can be shared across different settings. There is a caveat about forgetting to replace some parts, though.

/* definitions of helper types */
type AnswerRecordsNoBlanks<Qtype> = {
  [qName in keyof Qtype]:
  Qtype[qName] extends { answers: Readonly<unknown[]> } ?
  Qtype[qName]['answers'][number] : never
} 
type AnswerRecords<Qtype> = Partial<AnswerRecordsNoBlanks<Qtype>>

type Config<Qtype> = {
  calculate(ctx: { answers: AnswerRecords<Qtype> }): void | number;
  questions: Qtype;
}

/* set A config */
const QuestionSetA = {
  questionA1: {
    title: "Title A1",
    answers: ["A1_a", "A1_b", "A1_c"],
  },
  questionA2: {
    title: "Title A2",
    answers: ["A2_a", "A2_b"],
  },
} as const; 
type SetAType = typeof QuestionSetA;  
const config_setA: Config<SetAType> = {
  calculate({ answers }) {
    answers.questionA1 
    answers.questionA2
  },
  questions: QuestionSetA
}

/* set B config */
const QuestionSetB = {
  questionB1: {
    title: "Title B1",
    answers: ["B1_a", "B1_b", "B1_c"],
  },
  questionB2: {
    title: "Title B2",
    answers: ["B2_a", "B2_b"],
  },
} as const;
type SetBType = typeof QuestionSetB;  
const config_setB: Config<SetBType> = {
  calculate({ answers }) {
    answers.questionB1  
    answers.questionB2  
    return 100; 
  },
  questions: QuestionSetB
}

/* set C config */
//after some copy/pasting I realize it's easy to miss replacing 'B' with 'C' 
//e.g. in 'type SetCType = typeof QuestionSetC' and 'questions: QuestionSetC'
//while this is still manageable, a function-call approach like in the other answer may be more reliable? We may need to check.
const QuestionSetC = {
  questionC1: {
    title: "Title C1",
    answers: ["C1_a", "C1_b", "C1_c"],
  },
} as const;
type SetCType = typeof QuestionSetC; 
const config_setC: Config<SetCType> = {
  calculate({ answers }) { answers; },
  questions: QuestionSetC
}


/* try out calculations */
let setA_student1ans: AnswerRecords<SetAType> = { questionA1: 'A1_a' };
let setA_student2ans: AnswerRecords<SetAType> = { questionA2: 'A2_b' };
let setA_student3ans: AnswerRecords<SetAType> = { questionA1: 'A1_a', questionA2: 'A2_a' };

config_setA.calculate({ answers: setA_student1ans })
let setB_student1ans: AnswerRecords<SetBType> = { questionB1: 'B1_c' };
let setB_student2ans: AnswerRecords<SetBType> = { questionB2: 'B2_a' };
let setB_student3ans: AnswerRecords<SetBType> = { questionB1: 'B1_c', questionB2: 'B2_b' };
config_setB.calculate({ answers: setB_student1ans })

Upvotes: 2

Tobias S.
Tobias S.

Reputation: 23905

Edit:

As noted in the comments, the type of questionOne is not quite right yet. Here is an edit to fix this:

function useConfig<T extends Record<string, string>>(config: Config<T>){}

type CalculateCtx<T extends Record<string, string>> = { answers: {
  [K in keyof T]: T[K]
}}

type Question<V extends string> = {
  title: string;
  answers: V[];
}

type Config<T extends Record<string, string>> = {
  calculate(ctx: CalculateCtx<T>): void;
  questions: {
    [K in keyof T]: Question<T[K]>
  }
}

Playground


Something like this can be achieved when using the config object in a generic function.

function useConfig<T extends Record<string, Question>>(config: Config<T>){}

We use T to store the contents of questions.

type Config<T extends Record<string, Question>> = {
  calculate(ctx: CalculateCtx<T>): void;
  questions: T;
}

Config will have to be generic too where questions is T and the ctx of calculate is CalculateCtx<T>.

For CalculateCtx<T>, we use the keys of T to construct the shape of answers with a new Record.

type CalculateCtx<T> = { answers: Record<keyof T, string> }

You will now get the desired error when passing a config object literal to the function.

const config = useConfig({
  calculate({ answers }) {
    answers.questionOne
    answers.questionUnknown // Error
  },
  questions: {
    questionOne: {
      title: "Test Title",
      answers: ["answer-1", "answer-2", "answer-3"],
    },
  },
})

Playground

Upvotes: 1

Related Questions