From aec6f75dcd2b92415419024290db4b03ef6ef8d5 Mon Sep 17 00:00:00 2001 From: Luke Morales Date: Sun, 21 Jul 2024 19:53:54 -0300 Subject: [PATCH] feat: improve inference of return types --- .changeset/thin-dancers-reply.md | 22 ++ src/exhaustive.spec.ts | 460 +++++++++++++++++++++++++++---- src/exhaustive.ts | 108 ++++++-- 3 files changed, 502 insertions(+), 88 deletions(-) create mode 100644 .changeset/thin-dancers-reply.md diff --git a/.changeset/thin-dancers-reply.md b/.changeset/thin-dancers-reply.md new file mode 100644 index 0000000..d4f81dd --- /dev/null +++ b/.changeset/thin-dancers-reply.md @@ -0,0 +1,22 @@ +--- +'exhaustive': patch +--- + +Improve inference of return types with new generic value + +The return type of `exhaustive` and `exhaustive.tag` will now be inferred based on the return type of the callback function. + +```ts +type Union = 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR'; + +type HumanReadableUnion = 'idle' | 'loading' | 'success' | 'error'; + +function unionToHumanReadableString(union: Union): HumanReadableUnion { + return exhaustive(union, { + IDLE: () => 'idle', + LOADING: () => 'loading', + SUCCESS: () => 'success', + ERROR: (value) => value.toLowerCase(), // type `string` is not assignable to type `HumanReadableUnion` + }); +} +``` diff --git a/src/exhaustive.spec.ts b/src/exhaustive.spec.ts index 0602f78..27babe4 100644 --- a/src/exhaustive.spec.ts +++ b/src/exhaustive.spec.ts @@ -3,26 +3,104 @@ import { exhaustive } from './exhaustive'; describe('exhaustive', () => { describe('when used with two arguments', () => { - describe('when used with a union of strings', () => { + it('has the correct types', () => { type Union = 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR'; - type ExecOptions = { withFallback: boolean }; + function withMissingRequiredProperties(union: Union) { + // @ts-expect-error missing required properties + return exhaustive(union, { + IDLE: (value) => value.toLowerCase(), + }); + } - const exec = (union: Union, options?: ExecOptions) => - exhaustive(union, { + expectTypeOf(withMissingRequiredProperties).toEqualTypeOf< + (union: Union) => unknown + >(); + + function withUnknownProperties(union: Union) { + return exhaustive(union, { + IDLE: (value) => value.toLowerCase(), + ERROR: (value) => value.toLowerCase(), + LOADING: (value) => value.toLowerCase(), + SUCCESS: (value) => value.toLowerCase(), + // @ts-expect-error unknown property + extra: (value) => value.toLowerCase(), + }); + } + + expectTypeOf(withUnknownProperties).toEqualTypeOf< + (union: Union) => unknown + >(); + + function withInferredReturn(union: Union) { + return exhaustive(union, { IDLE: (value) => value.toLowerCase(), LOADING: (value) => value.toLowerCase(), SUCCESS: (value) => value.toLowerCase(), ERROR: (value) => value.toLowerCase(), - ...(options?.withFallback ? { _: () => '🚨' } : {}), + }); + } + + expectTypeOf(withInferredReturn).toEqualTypeOf< + (union: Union) => string + >(); + + type LowerUnion = Lowercase; + + function withTypedReturn(union: Union): LowerUnion { + return exhaustive(union, { + IDLE: () => 'idle', + LOADING: () => 'loading', + SUCCESS: () => 'success', + // @ts-expect-error value should be of type LowerUnion, but is of type string + ERROR: (value) => value.toLowerCase(), + }); + } + + expectTypeOf(withTypedReturn).toEqualTypeOf< + (union: Union) => LowerUnion + >(); + }); + + describe('when used with a union of strings', () => { + type Union = 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR'; + + type ExecOptions = { withFallback: boolean }; + + const exec = (union: Union, options?: ExecOptions) => + exhaustive(union, { + IDLE: (value) => { + expectTypeOf(value).toEqualTypeOf<'IDLE'>(); + + return value.toLowerCase(); + }, + LOADING: (value) => { + expectTypeOf(value).toEqualTypeOf<'LOADING'>(); + + return value.toLowerCase(); + }, + SUCCESS: (value) => { + expectTypeOf(value).toEqualTypeOf<'SUCCESS'>(); + + return value.toLowerCase(); + }, + ERROR: (value) => { + expectTypeOf(value).toEqualTypeOf<'ERROR'>(); + + return value.toLowerCase(); + }, + ...(options?.withFallback ? + { + _: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return '🚨'; + }, + } + : {}), }); - const eachCase = it.each([ - 'IDLE', - 'LOADING', - 'SUCCESS', - 'ERROR', - ] as const); + const eachCase = it.each(['IDLE', 'LOADING', 'SUCCESS', 'ERROR']); eachCase('returns the lower cased value for "%s"', (value) => { expect(exec(value)).toBe(value.toLowerCase()); @@ -30,13 +108,13 @@ describe('exhaustive', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); @@ -54,19 +132,43 @@ describe('exhaustive', () => { const exec = (union: Union, options?: ExecOptions) => exhaustive(union, { - IDLE: (value) => value.toLowerCase(), - LOADING: (value) => value.toLowerCase(), - SUCCESS: (value) => value.toLowerCase(), - ERROR: (value) => value.toLowerCase(), - ...(options?.withFallback ? { _: () => '🚨' } : {}), + IDLE: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return value.toLowerCase(); + }, + LOADING: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return value.toLowerCase(); + }, + SUCCESS: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return value.toLowerCase(); + }, + ERROR: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return value.toLowerCase(); + }, + ...(options?.withFallback ? + { + _: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return '🚨'; + }, + } + : {}), }); - const eachCase = it.each([ + const eachCase = it.each([ Union.IDLE, Union.LOADING, Union.SUCCESS, Union.ERROR, - ] as const); + ]); eachCase('returns the lower cased value for "%s"', (value) => { expect(exec(value)).toBe(value.toLowerCase()); @@ -74,13 +176,13 @@ describe('exhaustive', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); @@ -92,12 +194,20 @@ describe('exhaustive', () => { const exec = (condition: Condition, options?: ExecOptions) => exhaustive(condition, { - false: (value) => value.toString(), - true: (value) => value.toString(), + true: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return value.toString(); + }, + false: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return value.toString(); + }, ...(options?.withFallback ? { _: () => '🚨' } : {}), }); - const eachCase = it.each([true, false] as const); + const eachCase = it.each([true, false]); eachCase('returns the stringified value for "%s"', (value) => { expect(exec(value)).toBe(value.toString()); @@ -105,43 +215,136 @@ describe('exhaustive', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); }); describe('when used with three arguments', () => { - describe('when used with a discriminated union', () => { + it('has the correct types', () => { type TaggedUnion = | { state: 'IDLE' } | { state: 'LOADING' } | { state: 'SUCCESS'; data: string } | { state: 'ERROR'; error: string }; - type ExecOptions = { withFallback: boolean }; + function withMissingRequiredProperties(union: TaggedUnion) { + // @ts-expect-error missing required properties + return exhaustive(union, 'state', { + IDLE: (value) => value.state.toLowerCase(), + }); + } - const exec = (union: TaggedUnion, options?: ExecOptions) => - exhaustive(union, 'state', { + expectTypeOf(withMissingRequiredProperties).toEqualTypeOf< + (union: TaggedUnion) => unknown + >(); + + function withUnknownProperties(union: TaggedUnion) { + return exhaustive(union, 'state', { + IDLE: (value) => value.state.toLowerCase(), + ERROR: (value) => value.state.toLowerCase(), + LOADING: (value) => value.state.toLowerCase(), + SUCCESS: (value) => value.state.toLowerCase(), + // @ts-expect-error unknown property + extra: (value) => value.state.toLowerCase(), + }); + } + + expectTypeOf(withUnknownProperties).toEqualTypeOf< + (union: TaggedUnion) => unknown + >(); + + function withInferredReturn(union: TaggedUnion) { + return exhaustive(union, 'state', { IDLE: (value) => value.state.toLowerCase(), LOADING: (value) => value.state.toLowerCase(), SUCCESS: (value) => value.state.toLowerCase(), ERROR: (value) => value.state.toLowerCase(), - ...(options?.withFallback ? { _: () => '🚨' } : {}), }); + } - const eachCase = it.each([ + expectTypeOf(withInferredReturn).toEqualTypeOf< + (union: TaggedUnion) => string + >(); + + type LowerState = Lowercase; + + function withTypedReturn(union: TaggedUnion): LowerState { + return exhaustive(union, 'state', { + IDLE: () => 'idle', + LOADING: () => 'loading', + SUCCESS: () => 'success', + // @ts-expect-error value should be of type LowerUnion, but is of type string + ERROR: (value) => value.toLowerCase(), + }); + } + + expectTypeOf(withTypedReturn).toEqualTypeOf< + (union: TaggedUnion) => LowerState + >(); + }); + + describe('when used with a discriminated union', () => { + type TaggedUnion = + | { state: 'IDLE' } + | { state: 'LOADING' } + | { state: 'SUCCESS'; data: string } + | { state: 'ERROR'; error: string }; + + type ExecOptions = { withFallback: boolean }; + + const exec = (union: TaggedUnion, options?: ExecOptions) => + exhaustive(union, 'state', { + IDLE: (value) => { + expectTypeOf(value).toEqualTypeOf<{ state: 'IDLE' }>(); + + return value.state.toLowerCase(); + }, + LOADING: (value) => { + expectTypeOf(value).toEqualTypeOf<{ state: 'LOADING' }>(); + + return value.state.toLowerCase(); + }, + SUCCESS: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + state: 'SUCCESS'; + data: string; + }>(); + + return value.state.toLowerCase(); + }, + ERROR: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + state: 'ERROR'; + error: string; + }>(); + + return value.state.toLowerCase(); + }, + ...(options?.withFallback ? + { + _: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return '🚨'; + }, + } + : {}), + }); + + const eachCase = it.each([ { state: 'IDLE' }, { state: 'LOADING' }, { state: 'SUCCESS', data: '✅' }, { state: 'ERROR', error: '❌' }, - ] as TaggedUnion[]); + ]); eachCase( 'returns the lower cased value of the discriminator for "%s"', @@ -152,13 +355,13 @@ describe('exhaustive', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); @@ -172,15 +375,37 @@ describe('exhaustive', () => { const exec = (union: TaggedUnion, options?: ExecOptions) => exhaustive(union, 'checked', { - true: (value) => value.checked.toString(), - false: (value) => value.checked.toString(), - ...(options?.withFallback ? { _: () => '🚨' } : {}), + true: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + checked: true; + data: string; + }>(); + + return value.checked.toString(); + }, + false: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + checked: false; + error: string; + }>(); + + return value.checked.toString(); + }, + ...(options?.withFallback ? + { + _: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return '🚨'; + }, + } + : {}), }); - const eachCase = it.each([ + const eachCase = it.each([ { checked: true, data: '✅' }, { checked: false, error: '❌' }, - ] as TaggedUnion[]); + ]); eachCase( 'returns the lower cased value of the discriminator for "%s"', @@ -191,44 +416,137 @@ describe('exhaustive', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); }); }); -describe('exhaustive._tag', () => { - describe('when used with a discriminated union', () => { +describe('exhaustive.tag', () => { + it('has the correct types', () => { type TaggedUnion = | { state: 'IDLE' } | { state: 'LOADING' } | { state: 'SUCCESS'; data: string } | { state: 'ERROR'; error: string }; - type ExecOptions = { withFallback: boolean }; + function withMissingRequiredProperties(union: TaggedUnion) { + // @ts-expect-error missing required properties + return exhaustive.tag(union, 'state', { + IDLE: (value) => value.state.toLowerCase(), + }); + } - const exec = (union: TaggedUnion, options?: ExecOptions) => - exhaustive.tag(union, 'state', { + expectTypeOf(withMissingRequiredProperties).toEqualTypeOf< + (union: TaggedUnion) => unknown + >(); + + function withUnknownProperties(union: TaggedUnion) { + return exhaustive.tag(union, 'state', { + IDLE: (value) => value.state.toLowerCase(), + ERROR: (value) => value.state.toLowerCase(), + LOADING: (value) => value.state.toLowerCase(), + SUCCESS: (value) => value.state.toLowerCase(), + // @ts-expect-error unknown property + extra: (value) => value.state.toLowerCase(), + }); + } + + expectTypeOf(withUnknownProperties).toEqualTypeOf< + (union: TaggedUnion) => unknown + >(); + + function withInferredReturn(union: TaggedUnion) { + return exhaustive.tag(union, 'state', { IDLE: (value) => value.state.toLowerCase(), LOADING: (value) => value.state.toLowerCase(), SUCCESS: (value) => value.state.toLowerCase(), ERROR: (value) => value.state.toLowerCase(), - ...(options?.withFallback ? { _: () => '🚨' } : {}), }); + } - const eachCase = it.each([ + expectTypeOf(withInferredReturn).toEqualTypeOf< + (union: TaggedUnion) => string + >(); + + type LowerState = Lowercase; + + function withTypedReturn(union: TaggedUnion): LowerState { + return exhaustive.tag(union, 'state', { + IDLE: () => 'idle', + LOADING: () => 'loading', + SUCCESS: () => 'success', + // @ts-expect-error value should be of type LowerUnion, but is of type string + ERROR: (value) => value.toLowerCase(), + }); + } + + expectTypeOf(withTypedReturn).toEqualTypeOf< + (union: TaggedUnion) => LowerState + >(); + }); + + describe('when used with a discriminated union', () => { + type TaggedUnion = + | { state: 'IDLE' } + | { state: 'LOADING' } + | { state: 'SUCCESS'; data: string } + | { state: 'ERROR'; error: string }; + + type ExecOptions = { withFallback: boolean }; + + const exec = (union: TaggedUnion, options?: ExecOptions) => + exhaustive.tag(union, 'state', { + IDLE: (value) => { + expectTypeOf(value).toEqualTypeOf<{ state: 'IDLE' }>(); + + return value.state.toLowerCase(); + }, + LOADING: (value) => { + expectTypeOf(value).toEqualTypeOf<{ state: 'LOADING' }>(); + + return value.state.toLowerCase(); + }, + SUCCESS: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + state: 'SUCCESS'; + data: string; + }>(); + + return value.state.toLowerCase(); + }, + ERROR: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + state: 'ERROR'; + error: string; + }>(); + + return value.state.toLowerCase(); + }, + ...(options?.withFallback ? + { + _: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return '🚨'; + }, + } + : {}), + }); + + const eachCase = it.each([ { state: 'IDLE' }, { state: 'LOADING' }, { state: 'SUCCESS', data: '✅' }, { state: 'ERROR', error: '❌' }, - ] as TaggedUnion[]); + ]); eachCase( 'returns the lower cased value of the discriminator for "%s"', @@ -239,13 +557,13 @@ describe('exhaustive._tag', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); @@ -259,15 +577,37 @@ describe('exhaustive._tag', () => { const exec = (union: TaggedUnion, options?: ExecOptions) => exhaustive.tag(union, 'checked', { - true: (value) => value.checked.toString(), - false: (value) => value.checked.toString(), - ...(options?.withFallback ? { _: () => '🚨' } : {}), + true: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + checked: true; + data: string; + }>(); + + return value.checked.toString(); + }, + false: (value) => { + expectTypeOf(value).toEqualTypeOf<{ + checked: false; + error: string; + }>(); + + return value.checked.toString(); + }, + ...(options?.withFallback ? + { + _: (value) => { + expectTypeOf(value).toEqualTypeOf(); + + return '🚨'; + }, + } + : {}), }); - const eachCase = it.each([ + const eachCase = it.each([ { checked: true, data: '✅' }, { checked: false, error: '❌' }, - ] as TaggedUnion[]); + ]); eachCase( 'returns the lower cased value of the discriminator for "%s"', @@ -278,13 +618,13 @@ describe('exhaustive._tag', () => { describe('when no fallback is declared', () => { it('throws an exception when an invalid value is passed through', () => { - expect(() => exec('unknown' as any)).toThrow(TypeError); + expect(() => exec('unknown' as never)).toThrow(TypeError); }); }); describe('when a fallback is declared', () => { it('returns the declared fallback value', () => { - expect(exec('unknown' as any, { withFallback: true })).toBe('🚨'); + expect(exec('unknown' as never, { withFallback: true })).toBe('🚨'); }); }); }); diff --git a/src/exhaustive.ts b/src/exhaustive.ts index 00d17c7..8d19cca 100644 --- a/src/exhaustive.ts +++ b/src/exhaustive.ts @@ -1,22 +1,45 @@ /* eslint-disable prefer-object-has-own */ import { corrupt } from './corrupt'; -type ParseValue = - Value extends 'true' ? true - : Value extends 'false' ? false - : Value; - -export type ExhaustiveUnion = { - [Key in `${Union}`]: (value: ParseValue) => any; -} & ExhaustiveDefaultCase; - -export type ExhaustiveTag = { - [Key in `${Union[Tag] & (string | boolean)}`]: ( - value: Extract }>, - ) => any; -} & ExhaustiveDefaultCase; - -type ExhaustiveDefaultCase = { +type NoInfer = [T][T extends any ? 0 : never]; + +/** + * `unknown` extends `any` will be true, + * whereas `unknown` extends other types will be false + */ +type ValidateOutput = unknown extends T ? unknown : T; + +export type ExhaustiveUnion = + [Union] extends [string] ? + { + [Key in Union]: (value: Key) => Output; + } & ExhaustiveDefaultCase> + : Union extends boolean ? + { + true: (value: true) => Output; + false: (value: false) => Output; + } & ExhaustiveDefaultCase> + : never; + +export type ExhaustiveTag< + Union extends object, + Tag extends keyof Union, + Output = unknown, +> = + [Union[Tag]] extends [string] ? + { + [Key in `${Union[Tag]}`]: ( + value: Extract, + ) => Output; + } & ExhaustiveDefaultCase> + : Union[Tag] extends boolean ? + { + true: (value: Extract) => Output; + false: (value: Extract) => Output; + } & ExhaustiveDefaultCase> + : never; + +type ExhaustiveDefaultCase = { /** * Default case * @@ -24,7 +47,7 @@ type ExhaustiveDefaultCase = { * When declared, "exhaustive" will fallback to this case * instead of throwing an unreachable error on unmatched case */ - _?: () => any; + _?: (value: never) => Output; }; /** @@ -38,25 +61,46 @@ type ValidateKeys = function hasDefaultCase( match: object, -): match is Required { +): match is Required> { return Object.prototype.hasOwnProperty.call(match, '_'); } +type MatchCases = + unknown extends Output ? InferredCases : StrictCases; + +type ExtractOutput< + Cases extends Record unknown>, + Output, +> = + unknown extends Output ? ValidateOutput> + : Output; + function exhaustive< Union extends string | boolean, + Output, Cases extends ExhaustiveUnion = ExhaustiveUnion, - Output = ReturnType, ->(union: Union, match: ValidateKeys>): Output; +>( + union: Union, + match: MatchCases< + ValidateKeys>, + ExhaustiveUnion, + Output + >, +): ExtractOutput; function exhaustive< Union extends object, Tag extends keyof Union, + Output, Cases extends ExhaustiveTag = ExhaustiveTag, - Output = ReturnType, >( union: Union, tag: Tag, - match: ValidateKeys>, -): Output; + match: MatchCases< + ValidateKeys>, + ExhaustiveTag, + Output + >, +): ExtractOutput; function exhaustive( unionOrObject: string | object, casesOrKeyofUnion: string | object, @@ -79,7 +123,9 @@ function exhaustive( ); if (!matchesKey) { - return hasDefaultCase($cases) ? $cases._() : corrupt(union as never); + const never = union as never; + + return hasDefaultCase($cases) ? $cases._(never) : corrupt(never); } const event = $cases[union]; @@ -90,19 +136,25 @@ function exhaustive( exhaustive.tag = < Union extends object, Tag extends keyof Union, + Output, Cases extends ExhaustiveTag = ExhaustiveTag, - Output = ReturnType, >( union: Union, tag: Tag, - cases: ValidateKeys>, -): Output => { + cases: MatchCases< + ValidateKeys>, + ExhaustiveTag, + Output + >, +): ExtractOutput => { const key = union[tag]; const matchesKey: boolean = Object.prototype.hasOwnProperty.call(cases, key); if (!matchesKey) { - return hasDefaultCase(cases) ? cases._() : corrupt(union as never); + const never = union as never; + + return (hasDefaultCase(cases) ? cases._(never) : corrupt(never)) as never; } const event = cases[key as string];