javascripttypescriptdiscriminated-uniondiscriminator

Writing clever type guards for union type and generics


I have following structure:

const fragmentTypes = [
    'Word',
    'Sentence',
] as const;
type FragmentType = typeof fragmentTypes[number];

interface IFragmentData {
    type: FragmentType;
}

interface IFragment<T extends IFragmentData> {
    id: string;
    type: T['type'];
    data: Omit<T, 'type'>;
}

interface IWordFragmentData extends IFragmentData {
    type: 'Word';
    word: string;
}

interface ISentenceFragmentData extends IFragmentData {
    type: 'Sentence';
    sentence: string;
}

type Fragment =
    | IFragment<IWordFragmentData>
    | IFragment<ISentenceFragmentData>;

and know have the challenge that I often filter Fragments. My current way is by the following type guard:

function isFragmentType<T extends IFragmentData>(t: FragmentType) {
    return (x: Fragment | IFragment<T>): x is IFragment<T> => {
        return x.type === t;
    };
}
console.log(isFragmentType<IWordFragmentData>('Word')({type: 'Word', id: 'test123', data: {word: 'test123'}}));

This works fine but leaves the option to combine a IFragmentData with the wrong FragmentType. For example : isFragmentType<IMarkFragmentData>('Sentence') would be valid code even though 'Sentence' would be the wrong discriminator for the IMarkFragmentData type.

Is there a smarter way to write my type guard or even to restructure my typing?


Solution

  • The main problem with your isFragmentType() function is that the type of t is not constrained at all to T. I'd probably rewrite it so that T represents the type property, and use the Extract utility type to filter the Fragment union for the member with that type property:

    function isFragmentType<T extends Fragment['type']>(t: T) {
      return (x: Fragment): x is Extract<Fragment, { type: T }> => {
        return x.type === t;
      };
    }
    

    You can verify that this works as desired (and you don't have to manually specify T since it can be inferred from the type of t):

    function processFragment(f: Fragment) {
      if (isFragmentType("Word")(f)) {
        f.data.word.toUpperCase(); // okay
      } else {
        f.data.sentence.toUpperCase(); // okay
      }
    }
    

    FYI, I'm not sure why isFragmentType() is curried, but it doesn't look like it needs to be:

    function isFragmentType<T extends Fragment['type']>(
      t: T, x: Fragment
    ): x is Extract<Fragment, { type: T }> {
      return x.type === t;
    }
    
    function processFragment(f: Fragment) {
      if (isFragmentType("Word", f)) {
        f.data.word.toUpperCase(); // okay
      } else {
        f.data.sentence.toUpperCase(); // okay
      }
    }
    

    Playground link to code