Description
Suggestion
π Search Terms
Narrowing, User defined typed predicate, first-filter narrowing
β Viability Checklist
- This wouldn't be a breaking change in existing TypeScript/JavaScript code (Could break some code, but only the code that is already buggy and can produce runtime errors)
- 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
Let's take an example...
interface Dog { woof: () => void }
interface Cat { meow: () => void }
interface Fish { swim: () => void }
declare const isCatOrFish: (x: unknown) => x is Cat | Fish
declare let catOrDog: Cat | Dog
if (isCatOrFish(catOrDog)) {
catOrDog.meow()
catOrDog;
// ^? Cat
}
Okay so first off all, by definition of "narrowing", the narrowed catOrDog
should have been (Cat | Dog) & (Cat | Fish)
and not Cat
, both are not same. But TypeScript makes a good pragmatic decision to make it Cat
, and how does it do that? Via an algorithm Ryan describes here. To describe it better let me convert that algorithm into code...
type TypeScriptNarrow
< T // to-narrow
, N // narrowed
, ShouldBePragmatic =
// if any of the narrowed constituent is a subtype of any of the to-narrow constituent
( N extends unknown
? N extends T ? true : false
: never
) extends false
? false
: true
> =
ShouldBePragmatic extends true
? T extends unknown
? Extract<T, N>
: never
: T & N
interface Dog { woof: () => void }
interface Cat { meow: () => void }
interface Fish { swim: () => void }
interface Monkey { climb: () => void }
type T0 = TypeScriptNarrow<Dog | Cat, Cat>
// ^? Cat
type T1 = TypeScriptNarrow<Dog | Cat, Cat | Fish>
// ^? Cat
type T2 = TypeScriptNarrow<Dog | Monkey, Cat | Fish>
// ^? (Dog | Monkey) & (Cat | Fish)
type T3 = TypeScriptNarrow<{ a: string | undefined } | { b: string }, { a: string } | { b: string }>
// ^? { b: string }
The first three cases are good, the last one is problematic. Consider this...
const foo = (x: { a: string | undefined } | { b: string }) => {
if (areValuesDefined(x)) {
console.log(x.b.toUpperCase())
}
}
const areValuesDefined = <T>(x: T): x is { [K in keyof T]: Exclude<T[K], undefined> } =>
Object.values(x).every(x => x !== undefined)
foo({ a: "hello" }) // TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Here x
gets narrowed to { b: string }
(instead of something like { a: string } | { b: string }
) because of the current algorithm and makes the type system unsound.
So instead of the current algorithm I propose this algorithm...
type DevanshNarrow
< T // to-narrow
, N // narrowed
, ShouldBePragmatic =
// if any of the narrowed constituent is a subtype of any of the to-narrow constituent
( N extends unknown
? N extends T ? true : false
: never
) extends false
? false
: true
> =
ShouldBePragmatic extends true
? T extends unknown
? N extends unknown
? keyof T & keyof N extends never
? never
: T & N
: never
: never
: T & N
interface Dog { woof: () => void }
interface Cat { meow: () => void }
interface Fish { swim: () => void }
interface Monkey { climb: () => void }
type T0 = DevanshNarrow<Dog | Cat, Cat>
// ^? Cat
type T1 = DevanshNarrow<Dog | Cat, Cat | Fish>
// ^? Cat
type T2 = DevanshNarrow<Dog | Monkey, Cat | Fish>
// ^? (Dog | Monkey) & (Cat | Fish)
type T3 = DevanshNarrow<{ a: string | undefined } | { b: string }, { a: string } | { b: string }>
// ^? | ({ a: string | undefined } & { a: string })
// | ({ b: string } & { b: string })
So the first three results remain the same, hence it's still pragmatic but now the last result is more correct with DevanshNarrow
than TypeScriptNarrow
. With this algorithm the unsound code above would not compile.
The idea is instead of an actual intersection, we do a pragmatic intersection that is if two types don't overlap their pragmatic intersection is never
. And hence Dog <pragmaticIntersect> Cat
becomes never
I wonder if there are cases where TypeScript's current algorithm yields better results than the proposed.