Search code examples
typescripttype-inferenceconditional-types

Strict conditional filtering type (Typescript)


I am trying to create strict filtering interface:

type Filter<I, O extends I = I> = (value: I) => I extends O ? boolean : false

By writing this line I want to define a type constraint for a function which:

  1. receives value of type I
  2. returns false if value is not of expected type (I extends O is not true)
  3. returns true | false if value is of expected type and matches filter

But Typescript ignores conditional return type:

type MessageA = { type: 'A' }
type MessageB = { type: 'B' }
type Message = MessageA | MessageB

const filter: Filter<Message, MessageA> = ({ type }) => type === 'A'
const inputMessage: Message = { type: 'B' }

if (filter(inputMessage)) {
  // the following line produces error
  const message: MessageA = inputMessage
  // because according to TS compiler
  // inputMessage is still `MessageA | MessageB`
}

Logically filter(inputMessage) MAY produce true if inputMessage is of type MessageA.

I'd like to understand "is it achievable?" and "how to write it properly?" if it is.


I am not restricted with version of typescript, currently latest (for the moment) Typescript 3.9.5 is installed. I am using VSCode 1.46, whether it makes any difference.


Solution

  • Solution 1

    You can try this:

    type Filter<T, U extends T> = (candidate: T) => candidate is U;
    
    const filter: Filter<Message, MessageA> = (message): message is MessageA => message.type === 'A'
    

    But you still need to explicitly define the return type (: message is MessageA).

    Solution 2

    This one is more complex, but it makes your type guards (refinements) type-safe.

    Create a factory for type guards like this:

    namespace Refinement {
      class Hit<T> {
        constructor(readonly value: T) {}
      }
    
      class Miss {}
    
      type Result<T> = Hit<T> | Miss;
    
      export function hit<T> (value: T) {
        return new Hit(value);
      }
    
      export const miss = new Miss();
    
      export function create<T, U extends T>(refine: (candidate: T) => Result<U>): (candidate: T) => candidate is U {
        return (candidate): candidate is U => refine(candidate) instanceof Hit;
      }
    }
    

    Usage:

    declare const inputMessage: Message;
    
    const filter = Refinement.create(
      (message: Message) => message.type === 'A'
        ? Refinement.hit(message)
        : Refinement.miss
    )
    
    if (filter(inputMessage)) {
      inputMessage; // MessageA
    }
    

    This approach is used by fp-ts for example.