Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript does not recognize shared fields with conditional types and generic

Tags:

typescript

I have a conditional type that has two modes: Extender1 or Extender2. The modes share some common fields. I then have a smaller type that omits some of the fields of the conditional type. I then have a generic function to update the fields of the smaller extender. TypeScript seems to be stripping every field of the smaller type and not just the singular field I omitted. Is there a solution to getting TypeScript to recognize the shared fields or should I just give up on mixing conditional types and generics in TypeScript?

type AbstractBase = {
  a: string;
  b: string;
}

type Extender1 = AbstractBase & {
  c1: string;
}

type Extender2 = AbstractBase & {
  c2: string;
}

type ExtenderMode = 'Extender1' | 'Extender2';

type ConditionalExtender<M extends ExtenderMode> = M extends 'Extender1' ? {
  Extender: Extender1
} : { Extender: Extender2 }

type FullExtender<M extends ExtenderMode> = ConditionalExtender<M>["Extender"];

function updateFullExtender<M extends ExtenderMode, K extends keyof FullExtender<M>>(
  extender: FullExtender<M>,
  key: K,
  value: FullExtender<M>[K],
): FullExtender<M> {
  return {...extender, [key]: value};
}

function setFullExtenderAToThing<M extends ExtenderMode>(
  extender: FullExtender<M>
): FullExtender<M> {
  return updateFullExtender(extender, 'a', 'thing'); // works as expected
  // updateFullExtender(extender, 'c1', 'thing'); - this fails as expected since c1 doesn't exist on both types
}

const specificFullExtender: FullExtender<'Extender1'> = { a: 'a', b: 'b', c1: 'c1' };

// works
setFullExtenderAToThing(specificFullExtender);

// works
updateFullExtender(specificFullExtender, 'a', 'thing again');

// This fails but im not sure why as 'c1' exists on FullExtender<'Extender1'>
// Argument of type '"c1"' is not assignable to parameter of type '"a" | "b"'. ts(2345)
updateFullExtender(specificFullExtender, 'c1', 'thing again');
specificFullExtender.c1; // <-- this works fine

type ExtenderNoB<M extends ExtenderMode> = Omit<ConditionalExtender<M>["Extender"], 'b'>;

function updateNoBExtender<M extends ExtenderMode, K extends keyof ExtenderNoB<M>>(
  extender: ExtenderNoB<M>,
  key: K,
  value: ExtenderNoB<M>[K],
): ExtenderNoB<M> {
  return { ...extender, [key]: value };
}

// does NOT work
function setNoBAToThing<M extends ExtenderMode>(
  extender: ExtenderNoB<M>
): ExtenderNoB<M> {
  // 'a' exists on every ExtenderNoB<M>
  extender.a; // <-- can even access it fine here
  // Argument of type 'string' is not assignable to parameter of type 'Exclude<keyof ConditionalExtender<M>["Extender"], "b">'. ts(2345)
  return updateNoBExtender(extender, 'a', 'thing');
}

const specificNoBExtender: ExtenderNoB<'Extender1'> = {a: 'a', c1: 'c1'};
setNoBAToThing(specificNoBExtender); // can pass it in fine but as seen in the function typescript doesnt believe it has field 'a'
updateNoBExtender(specificNoBExtender, 'a', 'no thing'); // this actually works fine even though setNoBAToThing does not
updateNoBExtender(specificNoBExtender, 'c1', 'no thing'); // this does NOT fail even though it was failing in the updateFullExtender

For those curious, this is where the actual problem came from:

////////
// type helpers
////////

type OmitNever<T, K extends keyof T> = Omit<T, K> & {
  [P in K]?: never;
};
type AllOrNothing<T> = T | { [K in keyof T]: null };

////////
// backing database types
////////

type AssociatedProgramFields = {
  program_id: string;
  // some other fields here
};

type AbstractWorkout = {
  id: string;
  name: string;
  notes: string;
  workout_type: string;
};

type WorkoutLogRow = AbstractWorkout &
  AllOrNothing<AssociatedProgramFields> & {
    user_id: string;
    completed_on: string;
    // other fields here
  };

type WorkoutTemplateRow = AbstractWorkout & AssociatedProgramFields & {
  // other fields here
};

////////
// interface between UI and database
////////

type WorkoutEditorMode = "template" | "log";

type ModeTypes<M extends WorkoutEditorMode> = M extends "template"
  ?
    {
      WorkoutRow: WorkoutTemplateRow;
      WorkoutExerciseRow: {};
      WorkoutExerciseSetRow: {};
    }
  : 
    {
      WorkoutRow: WorkoutLogRow;
      WorkoutExerciseRow: {};
      WorkoutExerciseSetRow: {};
  };

type CompleteProgram = OmitNever<
  AssociatedProgramFields,
  "program_id"
> & {
  program_row: {};
};

type EditableProgramFields<M extends WorkoutEditorMode> =
  M extends "template" ? CompleteProgram : AllOrNothing<CompleteProgram>;

type EditableWorkout<M extends WorkoutEditorMode> = Omit<
  ModeTypes<M>["WorkoutRow"],
  "id" | keyof AssociatedProgramFields
> &
  EditableProgramFields<M> & { id?: never };
type EditableExercise<M extends WorkoutEditorMode> = ModeTypes<M>["WorkoutExerciseRow"];
type EditableSet<M extends WorkoutEditorMode> = ModeTypes<M>["WorkoutExerciseSetRow"];

type FullDetachedWorkoutForMode<M extends WorkoutEditorMode> = {
  workout: EditableWorkout<M>;
  exercises: EditableExercise<M>[];
  sets: EditableSet<M>[][];
};

// problematic updater
function updateWorkoutInWorkout<M extends WorkoutEditorMode, K extends keyof EditableWorkout<M>>(args: {
  fullWorkout: FullDetachedWorkoutForMode<M>;
  key: K;
  value: EditableWorkout<M>[K];
}): FullDetachedWorkoutForMode<M> {
  const { fullWorkout, key, value } = args;
  fullWorkout.workout = { ...fullWorkout.workout, [key]: value };
  return fullWorkout;
}

////////
// UI side
////////

// I have a screen that look something like this
function SomeWorkoutScreen<M extends WorkoutEditorMode>(props: {initWorkoutMode: FullDetachedWorkoutForMode<M>}) {
  const updatedWorkoutName = () => {
    // I expect 'name' to exist on both types however typescript has stripped all fields
    // Type '"name"' is not assignable to type '"id" | Exclude<keyof ModeTypes<M>["WorkoutRow"], keyof AssociatedProgramFields | "id"> | keyof EditableProgramFields<M>'.
    return updateWorkoutInWorkout({fullWorkout: props.initWorkoutMode, key: 'name', value: 'new name'})
  }
}
like image 802
Brandon Avatar asked Sep 26 '26 14:09

Brandon


1 Answers

The general problem is that TypeScript can't really do too much analysis when you "mix" generics with conditional types. Once a generic type parameter is specified with a specific type argument, TypeScript can just evaluate the conditional type to get the resulting type, but when there's an unresolved type parameter, it mostly just leaves the type deferred and doesn't try too much.

So inside the body of your generic functions TypeScript will disappoint you when it is unable to perform higher-order analysis. The body of updateNoBExtender() is like this. If you need this sort of function, you'll probably have to use type assertions or the like inside the body.

And also, when you try to call a generic function where the generic type parameter is only mentioned as the argument of a conditional type, TypeScript fails to infer a type argument. in order for TypeScript to infer M from a value of type FullExtender<M>, it would need to reverse the definition of FullEntender<M>. The question "for which possible M is FullExtender<M> assignable to FullExtender<'Extender1'>" seems like it has an obvious answer by pattern matching, but FullExtender<'Extender1'> is immediately evaluated as Extender1. And so now you're asking "for which possible M is FullExtender<M> assignable to Extender1, and TypeScript effectively gives up. It does not iterate through various possible M candidates. Instead it says "I have no idea from where I should infer this" and just falls back to the constraint ExtenderMode.


In order to proceed I would generally try to:

  • avoid generic conditional types anywhere they are likely to be used with the generic type parameter unresolved, and
  • only try to infer generic type arguments from values very directly related to the relevant generic type parameters. Inferring M from FullExtender<M> is hard, inferring F from F is easy.

So here's a possible refactoring of your example code:

type FullExtender = Extender1 | Extender2
function updateFullExtender<F extends FullExtender, K extends keyof F>(
    extender: F, key: K, value: F[K],): F {
    return { ...extender, [key]: value };
}
function setFullExtenderAToThing<F extends FullExtender>(extender: F): F {
    //updateFullExtender(extender, 'c1', 'thing'); // fails as expected
    return updateFullExtender(extender, 'a', 'thing'); // works as expected
}
const specificFullExtender: Extender1 = { a: 'a', b: 'b', c1: 'c1' };
setFullExtenderAToThing(specificFullExtender);
updateFullExtender(specificFullExtender, 'a', 'thing again');
updateFullExtender(specificFullExtender, 'c1', 'thing again');

type DistribOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
type FullExtenderNoB = DistribOmit<FullExtender, "b">;

function updateNoBExtender<F extends FullExtenderNoB, K extends keyof F>(
    extender: F, key: K, value: F[K]): F {
    return { ...extender, [key]: value };
}

function setNoBAToThing<F extends FullExtenderNoB>(extender: F): F {
    return updateNoBExtender(extender, 'a', 'thing'); // works
}

const specificNoBExtender: Omit<Extender1, 'b'> = { a: 'a', c1: 'c1' };
setNoBAToThing(specificNoBExtender);
updateNoBExtender(specificNoBExtender, 'a', 'no thing');
updateNoBExtender(specificNoBExtender, 'c1', 'no thing'); 

What I've done here is give up completely on M since it's just a nearly arbitrary label that complicates things. There are definitely use cases where such labels are useful, but I don't see it here. If that happens you might need to use the approach as described in microsoft/TypeScript#47109 and various answers I've posted on Stack Overflow that mention it.

Instead of trying to infer the label from some extender, we just infer the extender from itself. That's F. Now much of the code just starts to work. Yes, it's possible that F might be some subtype of FullExtender that you didn't expect, but the basic spreading code mostly works for those cases too. If you run into a use case where you need to prohibit a certain input for updateFullExtender() and updateNoBExtender(), you can probably do that with this same general approach.

The DistribOmit thing is just a convenience type to produce FullExtenderNoB but you could write that out yourself. Again, updateNoBExtender just uses F but the constraint is FullExtenderNoB. Yes, this might allow some inputs you don't expect, but again, the basic spreading code should still work there.

So that's my first-order refactoring of this code. If you ever find yourself needing to restrict a function call to some enumerated list of string literal related types, microsoft/TypeScript#47109 is the way to do that. But you'd probably better make sure those string literals are actually present in the arguments to your function. So the call would look like f("Extender1") or g({type: "Extender1"}). Otherwise it's likely to be just too much indirection.

Playground link to code

like image 81
jcalz Avatar answered Sep 30 '26 18:09

jcalz



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!