Skip to content

Commit

Permalink
Improved typing for union types (#2151)
Browse files Browse the repository at this point in the history
* Have unions not devolve into `IAnyType` with 10+ members.

* Improve typings for enums

* Remove no longer used scripts/generate-union-types.js

* Add an additional literal to union typechecking test

* spike: issue 1525 still broken

* test: add test for issue 1664

* spike: failing test for 1833

* test: remove failing tests

* test: add describe/test blocks

* test: remove failing tests (again)

* test: add test for issue 1525

---------

Co-authored-by: Tyler Williams <[email protected]>
  • Loading branch information
thegedge and coolsoftwaretyler authored Mar 1, 2024
1 parent 46334b6 commit 8238701
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 181 deletions.
33 changes: 33 additions & 0 deletions __tests__/core/1525.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { types, Instance } from "../../src/index"

describe("1525. Model instance maybe fields becoming TypeScript optional fields when included in a types.union", () => {
it("does not throw a typescript error", () => {
const Model = types.model("myModel", {
foo: types.string,
bar: types.maybe(types.integer)
})

const Store = types.model("store", {
itemWithoutIssue: Model,
itemWithIssue: types.union(types.literal("anotherValue"), Model)
})

interface IModel extends Instance<typeof Model> {}

interface FunctionArgs {
model1: IModel
model2: IModel
}

const store = Store.create({
itemWithoutIssue: { foo: "works" },
itemWithIssue: { foo: "has ts error in a regression" }
})

const f = (props: FunctionArgs) => {}

const itemWithoutIssueModel = store.itemWithoutIssue
const itemWithIssueModel = store.itemWithIssue === "anotherValue" ? null : store.itemWithIssue
itemWithIssueModel && f({ model1: itemWithoutIssueModel, model2: itemWithIssueModel })
})
})
33 changes: 33 additions & 0 deletions __tests__/core/1664.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { types as t } from "../../src/index"

describe("1664. Array and model types are not inferred correctly when broken down into their components", () => {
test("should not throw a typescript error", () => {
// Simple concrete type with a creation type different than its instance type
const date = t.custom<string, Date>({
name: "Date",
fromSnapshot: (snapshot) => new Date(snapshot),
toSnapshot: (dt) => dt.toISOString(),
isTargetType: (val: unknown) => val instanceof Date,
getValidationMessage: (snapshot: unknown) =>
typeof snapshot !== "string" || isNaN(Date.parse(snapshot))
? `${snapshot} is not a valid Date string`
: ""
})

//Wrap the date type in an array type. IArrayType is a sub-interface of IType.
const DateArray = t.array(date)

//Pass the array type to t.union, which infers the component types as <C, S, T>
const LoadableDateArray = t.union(t.literal("loading"), DateArray)

//Instantiate the type
const lda = LoadableDateArray.create([])

//Try to use the array type as an instance
if (lda !== "loading") {
//Error: type of lda is essentially `(string | Date)[] | undefined`
//The creation type has been mixed together with the instance type
const dateArray: Date[] = lda
}
})
})
48 changes: 43 additions & 5 deletions __tests__/core/type-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,21 @@ type DifferingKeys<ActualT, ExpectedT> = {
}[keyof ActualT | keyof ExpectedT] &
string

type InexactErrorMessage<ActualT, ExpectedT> = `Mismatched property: ${DifferingKeys<
ActualT,
ExpectedT
>}`
type NotExactErrorMessage<ActualT, ExpectedT> = ActualT extends Record<string, unknown>
? ExpectedT extends Record<string, unknown>
? `Mismatched property: ${DifferingKeys<ActualT, ExpectedT>}`
: "Expected a non-object type, but received an object"
: ExpectedT extends Record<string, unknown>
? "Expected an object type, but received a non-object type"
: "Types are not exactly equal"

type IsExact<T1, T2> = [T1] extends [T2] ? ([T2] extends [T1] ? Exact<T1, T2> : never) : never

const assertTypesEqual = <ActualT, ExpectedT>(
t: ActualT,
u: Exact<ActualT, ExpectedT> extends never ? InexactErrorMessage<ActualT, ExpectedT> : ExpectedT
u: IsExact<ActualT, ExpectedT> extends never
? NotExactErrorMessage<ActualT, ExpectedT>
: ExpectedT
): [ActualT, ExpectedT] => [t, u] as [ActualT, ExpectedT]
const _: unknown = undefined

Expand Down Expand Up @@ -1225,3 +1232,34 @@ test("object creation when composing with a model with no props", () => {
// @ts-expect-error -- unknown prop
true || Composed.create({ another: 5 })
})

test("union type inference verification for small number of types", () => {
const T = types.union(types.boolean, types.literal("test"), types.maybe(types.number))

type ITC = SnapshotIn<typeof T>
type ITS = SnapshotOut<typeof T>

assertTypesEqual(_ as ITC, _ as boolean | "test" | number | undefined)
assertTypesEqual(_ as ITS, _ as boolean | "test" | number | undefined)
})

test("union type inference verification for a large number of types", () => {
const T = types.union(
types.literal("a"),
types.literal("b"),
types.literal("c"),
types.literal("d"),
types.literal("e"),
types.literal("f"),
types.literal("g"),
types.literal("h"),
types.literal("i"),
types.literal("j")
)

type ITC = SnapshotIn<typeof T>
type ITS = SnapshotOut<typeof T>

assertTypesEqual(_ as ITC, _ as "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j")
assertTypesEqual(_ as ITS, _ as "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j")
})
69 changes: 0 additions & 69 deletions scripts/generate-union-types.js

This file was deleted.

18 changes: 10 additions & 8 deletions src/types/utility-types/enumeration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ export type UnionStringArray<T extends readonly string[]> = T[number]
// these overloads also allow both mutable and immutable arrays, making types.enumeration<Enum>(Object.values(Enum)) possible.
// the only case where this doesn't work is when passing to the function an array variable with a mutable type constraint;
// for these cases, it will just fallback and assume the type is a generic string.
export function enumeration<T extends readonly string[]>(
options: T
): ISimpleType<UnionStringArray<T>>
export function enumeration<T extends string>(
options: readonly T[]
): ISimpleType<UnionStringArray<T[]>>
export function enumeration<T extends string>(
name: string,
options: T[]
options: readonly T[]
): ISimpleType<UnionStringArray<T[]>>

/**
* `types.enumeration` - Can be used to create an string based enumeration.
* (note: this methods is just sugar for a union of string literals)
Expand All @@ -31,8 +30,11 @@ export function enumeration<T extends string>(
* @param options possible values this enumeration can have
* @returns
*/
export function enumeration(name: string | string[], options?: any): ISimpleType<string> {
const realOptions: string[] = typeof name === "string" ? options! : name
export function enumeration<T extends string>(
name: string | readonly T[],
options?: readonly T[]
): ISimpleType<T[number]> {
const realOptions: readonly T[] = typeof name === "string" ? options! : name
// check all options
if (devMode()) {
realOptions.forEach((option, i) => {
Expand All @@ -41,5 +43,5 @@ export function enumeration(name: string | string[], options?: any): ISimpleType
}
const type = union(...realOptions.map((option) => literal("" + option)))
if (typeof name === "string") type.name = name
return type
return type as ISimpleType<T[number]>
}
Loading

0 comments on commit 8238701

Please sign in to comment.