Skip to content

Commit 3b571e3

Browse files
authored
Merge pull request #3 from J0m1ty/dev-next
Simplify types, address bugs, and improve type safety
2 parents 16597dd + a45dac7 commit 3b571e3

File tree

2 files changed

+85
-92
lines changed

2 files changed

+85
-92
lines changed

README.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Build Status](https://github.com/J0m1ty/ts-safe-union/workflows/CI/badge.svg)](https://github.com/J0m1ty/ts-safe-union/actions)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
66

7-
A TypeScript utility for destructuring discriminated unions while maintaining type safety. This package is tiny (no dependencies) and is niche but simple to use and solves a common problem in Typescript. Broadly, this package helps with:
7+
A TypeScript utility for destructuring discriminated unions while maintaining type safety. This package is tiny (no dependencies) and solves a common problem in Typescript. Broadly, this package helps with:
88
- Safely destructuring discriminated unions
99
- Making switch/case statements exhaustive
1010
- Improving state management patterns
@@ -67,17 +67,9 @@ type RequestState = DiscriminatedUnion<
6767
}
6868
>;
6969

70-
// Optional: Define common properties
71-
type RequestStateWithCommon = RequestState & {
72-
id: string;
73-
timestamp: number;
74-
};
75-
76-
// Example implementation
70+
// Example request consumer
7771
const handleRequest = (request: RequestStateWithCommon) => {
78-
const { status, id, timestamp, progress, data, error } = request;
79-
80-
console.log(`Processing request ${id} from ${timestamp}`);
72+
const { status, progress, data, error } = request;
8173

8274
if (status === "loading") {
8375
console.log(`Loading: ${progress}%`);
@@ -89,6 +81,23 @@ const handleRequest = (request: RequestStateWithCommon) => {
8981
};
9082
```
9183

84+
If you want to define your keys elsewhere, you can provide them to the Discriminated Union and the TS complier will give you errors if some variant are not supplied in the list. For example, the following example will have a type error because the error variant is not specified.
85+
86+
```typescript
87+
import { DiscriminatedUnion } from "ts-safe-union";
88+
89+
const statusKeys = ["loading", "success", "error"] as const;
90+
91+
type RequestState = DiscriminatedUnion<
92+
"status",
93+
{
94+
loading: { progress: number };
95+
success: { data: unknown };
96+
},
97+
typeof statusKeys[number] // << error here
98+
>;
99+
```
100+
92101
### MergedUnion
93102

94103
Use `MergedUnion` when you want to merge two existing object types into a single union while maintaining safe property access.
@@ -123,12 +132,11 @@ Check out the [examples directory](./examples) for simple but practical use case
123132

124133
## Contributing
125134

126-
Contributions are welcome! Please feel free to submit a PR. Make sure to:
135+
Contributions are welcome! Please feel free to submit a PR. If you feel comfortable, also:
127136

128137
1. Add tests for any new features
129138
2. Update documentation if needed
130-
3. Follow the existing code style
131-
4. Ensure all tests pass by running `npm test`
139+
3. Ensure all tests pass by running `npm test`
132140

133141
## License
134142

src/index.ts

Lines changed: 63 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,97 +4,82 @@
44
* @packageDocumentation
55
*/
66

7-
///////////// DISCRIMINATED UNION //////////////
8-
97
/**
10-
* Utility type that converts a union type to an intersection type.
11-
* @internal
8+
* Extract all property keys that appear in any member of a union.
129
*/
13-
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
14-
k: infer I
15-
) => void
16-
? I
17-
: never;
10+
type UnionKeys<U> = U extends any ? keyof U : never;
1811

1912
/**
20-
* Utility type that tags a single state with a discriminator value.
21-
* @internal
13+
* Build one member of the discriminated union. The construction does three things:
14+
* 1. Includes the original `Member` properties
15+
* 2. Injects `{ [Discriminator]: TagName }` so the member is uniquely tagged
16+
* 3. Adds any missing keys from `AllKeys` as optional `?: undefined`
17+
*
18+
*
19+
* @template TagName literal tag that identifies this member (e.g. "circle")
20+
* @template Member object type supplied for this tag (e.g. { radius: number })
21+
* @template Discriminator key that stores the tag (e.g. "kind")
22+
* @template AllKeys union of keys across all members
2223
*/
23-
type TaggedState<TDiscriminator extends string, K extends string, State> = {
24-
[P in TDiscriminator]: K;
25-
} & State;
24+
type BuildMember<
25+
TagName extends PropertyKey,
26+
Member extends object,
27+
Discriminator extends PropertyKey,
28+
AllKeys extends PropertyKey
29+
> =
30+
Member &
31+
{ [P in Discriminator]: TagName } &
32+
{ [P in Exclude<AllKeys, keyof Member>]?: undefined };
2633

2734
/**
28-
* Utility type that produces a union of all tagged states from the given record.
29-
* @internal
30-
*/
31-
type AllTaggedStates<
32-
TDiscriminator extends string,
33-
TStates extends Record<string, any>
35+
* Transform a map of variants into a clean discriminated union.
36+
*
37+
* @example
38+
* ```ts
39+
* type RequestState = DiscriminatedUnion<
40+
* "status",
41+
* {
42+
* loading: { progress: number };
43+
* success: { data: unknown };
44+
* error: { error: Error };
45+
* }
46+
* >;
47+
* ```
48+
*/
49+
export type DiscriminatedUnion<
50+
Discriminator extends PropertyKey,
51+
Variants extends Record<PropertyKey, object>,
52+
Tags extends keyof Variants = keyof Variants
3453
> = {
35-
[K in keyof TStates]: TaggedState<TDiscriminator, K & string, TStates[K]>;
36-
}[keyof TStates];
54+
[Tag in Tags]:
55+
BuildMember<
56+
Tag & string,
57+
Variants[Tag],
58+
Discriminator,
59+
UnionKeys<Variants[Tags]>
60+
>
61+
}[Tags];
3762

3863
/**
39-
* Utility type that extracts common property keys from all states.
40-
* @internal
64+
* Cleanly expands union types into a single object type.
4165
*/
42-
type SharedProperties<TStates extends Record<string, any>> = {
43-
[K in keyof UnionToIntersection<TStates[keyof TStates]>]?: unknown;
44-
};
66+
type Expand<T> = { [K in keyof T]: T[K]; }
4567

4668
/**
47-
* Creates a safely destructurable discriminated union type that maintains type safety.
48-
*
49-
* @template TDiscriminator - The string literal type that will be used as the discriminator property name
50-
* @template TStates - An object type mapping discriminator values to their respective property types
69+
* Merge two object types so that properties present in both objects become a union of their types and properties exclusive to one side are kept optional.
5170
*
5271
* @example
53-
* type RequestState = SafeUnion<"status", {
54-
* loading: { progress: number }; // Will have status: "loading"
55-
* success: { data: unknown }; // Will have status: "success"
56-
* error: { error: Error }; // Will have status: "error"
57-
* }>;
58-
*/
59-
export type DiscriminatedUnion<
60-
TDiscriminator extends string,
61-
TStates extends Record<string, any>
62-
> = AllTaggedStates<TDiscriminator, TStates> & SharedProperties<TStates>;
63-
64-
///////////// MERGE UNION //////////////
65-
66-
/**
67-
* Utility type to merge the value for a single property key from T and U.
68-
* @internal
69-
*/
70-
type MergePropertyValue<
71-
T extends object,
72-
U extends object,
73-
K extends PropertyKey
74-
> = K extends keyof T
75-
? K extends keyof U
76-
? T[K] | U[K] // if both T and U have the key K, merge their values
77-
: T[K] | unknown // if only T has the key K, make it possibly unknown
78-
: K extends keyof U
79-
? U[K] | unknown // if only U has the key K, make it possibly unknown
80-
: never;
81-
82-
/**
83-
* Constructs an object type that includes all keys from T and U,
84-
* merging the property types using MergePropertyValue.
85-
* @internal
86-
*/
87-
type MergedProperties<T extends object, U extends object> = {
88-
[K in keyof T | keyof U]?: MergePropertyValue<T, U, K>;
89-
};
90-
91-
/**
92-
* Merges two object types T and U into a single type that includes all properties from both,
93-
* with overlapping properties merged into a union type.
94-
* @internal
72+
* ```ts
73+
* type A = { id: string; name: string };
74+
* type B = { id: number; age: number };
75+
*
76+
* type AB = MergedUnion<A, B>;
9577
*/
96-
export type MergedUnion<T extends object, U extends object> = MergedProperties<
97-
T,
98-
U
99-
> &
100-
(T | U);
78+
export type MergedUnion<
79+
First extends object,
80+
Second extends object
81+
> = Expand<
82+
{ [K in Extract<keyof First, keyof Second>]: First[K] | Second[K] } &
83+
{ [K in Exclude<keyof First, keyof Second>]?: First[K] } &
84+
{ [K in Exclude<keyof Second, keyof First>]?: Second[K] }
85+
>;

0 commit comments

Comments
 (0)