Skip to content

Narrow discriminated union based on generic function arguments #46899

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
5 tasks done
benwainwright opened this issue Nov 22, 2021 · 6 comments
Open
5 tasks done

Narrow discriminated union based on generic function arguments #46899

benwainwright opened this issue Nov 22, 2021 · 6 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@benwainwright
Copy link

Suggestion

πŸ” Search Terms

narrowing, generic, arguments

βœ… Viability Checklist

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

In a few different projects, I've wanted to write a wrapper function that takes as arguments

  • A discriminating union
  • A literal discriminator
  • A callback function which accepts the narrowed type from the union

To be more specific, I wanted to be able to do something like this:

interface Thing {
  key: "bar";
  property1: number;
}

interface OtherThing {
  key: "baz";
  property2: string;
}

type Things = Thing | OtherThing;

type NarrowedThing<A, K> = A extends { key: K } ? A : never;

const narrowingFunction = <T extends Things, K extends T["key"]>(
  thing: T,
  key: K,
  callback: (thing: NarrowedThing<T, K>) => void
) => {
  if (thing.key === key) {
    callback(thing); // Type 'Thing' is not assignable to type 'NarrowedThing<T, K>'.(2345)
  }
};

const maybeThing: Things = {
  key: "bar",
  property1: 2,
};

narrowingFunction(maybeThing, "bar", (narrowedThing: Thing) => {
  console.log(narrowedThing.property1);
});

Now it may be that my understanding of TypeScript is too limited to understand what is possible here, but it seems to me that it would be correct for thing to be automatically narrowed to something like my NarrowedThings type

πŸ“ƒ Motivating Example

I recently tried to write a small library to simplify the handling of AWS EventBridge events in a way that allowed you to provide handler functions and cleanly narrowed the types. The API looked a little like this:

const { step, execute } = initPipeline(event);

// Both 'processor' variables below are handler functions that
// take a single event object narrowed to the correct type using the 
// string that is passed in argument one as a discriminator
execute(
  step('EventOne', eventOneProcessor),
  step('EventTwo', eventTwoProcessor),
)

Under the hood, this API is just an abstraction over the simple 3 argument function I describe above. In order to narrow the type I had to write a custom type guard as there appeared to be no way for TypeScript to narrow arguments that were typed with generics.

πŸ’» Use Cases

I've also wanted something like this when handling redux actions within a reducer - I can see libraries such as ts-pattern making use of this under the hood. As I note above, it is perfectly possible to implement this with a custom type guard, but it seems to me that everything that is needed to safely narrow the type is known at build time so a custom guard shouldn't be necessary.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Nov 22, 2021
@RyanCavanaugh
Copy link
Member

The example as written is pretty tough; I'm not sure how we could do that.

A possibly tractable (and functionally equivalent version) would be

const narrowingFunction = <T extends Things, K extends T["key"]>(
  thing: T,
  key: K,
  callback: (thing: T & { key: K }) => void
) => {
  if (thing.key === key) {
    callback(thing); // Type 'Thing' is not assignable to type 'NarrowedThing<T, K>'.(2345)
  }
};

The relationship between the comparison and the narrowing is more clear here -- thing.key === key is an easy-to-see proof that thing is T & { key: K } and the call should be legal.

@benwainwright
Copy link
Author

benwainwright commented Nov 22, 2021

@RyanCavanaugh I've just played with that in the playground; I'm happy that this satisfies the use case I was trying to articulate :)

@benwainwright
Copy link
Author

@RyanCavanaugh if I wanted to have a go at implementing this myself, what areas of the codebase would I need to
look at?

I appreciate that it might be beyond me as a new contributor, but I'd be interested in at least having a poke to see if it's doable

@Oblarg
Copy link

Oblarg commented Dec 27, 2022

I'd also like to have a go at this if possible, or at least get an update on whether this is on the roadmap. Here's a similar case:

// Pretty simple discriminated union...
type Circle = {
    type: 'circle';
    radius: number;
}

type Square = {
    type: 'square';
    width: number;
}

type Shape = Circle | Square;

// What if we want a generic factory?
// It would be really cool if the compiler could infer this return type from the function implementation
// Even with the explicit conditional return type, there are problems...
const shape = <T extends Shape['type']>(type: T, size: number): T extends 'circle' ? Circle : T extends 'square' ? Square : Shape => {
    switch(type) {
        case 'circle':
            // Ideally the compiler would be smart enough to narrow T here
            return { type: 'circle', radius: size };
        case 'square':
            // and here
            return { type: 'square', width: size };
        default:
            throw new Error ("invalid shape type");
    }
}

// From the outside, though, this seems to work correctly:

// Basic usage results in correct inference/typing
let circle = shape('circle', 5);
let square = shape('square', 5);

// Reasonably infers union type if key is uncertain
let isCircle = false;
let maybeCircle = shape(isCircle ? 'circle' : 'square', 5);

Any thoughts of the feasibility of making the inferred function type behave better here along with fixing the issue?

@johnw42
Copy link

johnw42 commented May 11, 2023

It kind of seems to me like the original code should work, but the issue can be worked around using the & operator, leading to much simpler code:

type NarrowedThing<K> = Things & { key: K };

const narrowingFunction = <K extends Things["key"]>(
  thing: NarrowedThing<K>,
  key: K,
  callback: (thing: NarrowedThing<K>) => void
) => {
  if (thing.key === key) {
    callback(thing);
  }
};

On the other hand, the same idea doesn't work on @Oblarg's example. This still fails:

const shape = <T extends Shape['type']>(type: T, size: number): Shape & { type: T } => {
  switch (type) {
    case 'circle':
      return { type, radius: size };
    case 'square':
      return { type, width: size };
    default:
      throw new Error("invalid shape type");
  }
}

I think the original example also fails for another unrelated reason, because the switch statement here also breaks, but it works if the literal variables in the return statements are replaced with type:

const classify = <T extends Shape['type']>(type: T): T => {
  switch (type) {
    case 'circle':
      return 'circle';
    case 'square':
      return 'square';
    default:
      throw new Error("invalid shape type");
  }
}

@johnw42
Copy link

johnw42 commented May 11, 2023

On the third hand, I think the factory function example makes more sense using function overloading:

function shape(type: "circle", size: number): Circle;
function shape(type: "square", size: number): Square;
function shape(type: Shape["type"], size: number): Shape {...}

This loses some generality, but I don't think it's useful generality, because shape already has to handle Shape subtypes on a case-by-cases basis. Adding overloads instead of a fancy generic signature is a little more verbose but much more readable, and it's also more flexible, because it lets you do things like add a "rectangle" type with length and width parameters:

function shape(type: "circle", size: number): Circle;
function shape(type: "square", size: number): Square;
function shape(type: "rectangle", length: number, width: number): Rectangle;
function shape(type: Shape["type"], size1: number, size2?: number): Shape {...}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants