-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow circular constraints #51011
Comments
A lot of this is caused by needlessly writing naked type parameters. For example you could equivalently write type Basket = { bananas: number, apple: number }
declare const updateBasket: <T>(basket: ShallowExact<T, Partial<Basket>>) => void
updateBasket(({ banana: basket.bananas + 1, apple: 10 }))
// Error is found and similarly type Basket = { bananas: number, apple: number }
declare const updateBasket: <T>(f: (state: Basket) => ShallowExact<T, Basket>) => void which would error on the following line if #241 were addressed. I'm not really aware of any coherent pattern that can't be expressed by either lifting out the constraint into the use site or adding an additional type parameter. |
In those cases yes you could refactor like that, but how about this... declare const placeOrder: <T extends [Order<T[0]>]>(...order: T) => void
type Order<Self> =
Self extends number[] ? number[] :
{ ids: number[]
, urgent: Self["ids" & keyof Self]
}
declare const orderA: 1
declare const orderB: 2
declare const orderC: 3
placeOrder([orderA, orderB])
// okay, good
placeOrder({ ids: [orderA, orderB], urgent: [orderC] })
// nope, `orderC` can't be marked as urgent as it's not being placed This can't be refactored to I know one won't need a circular constraint in everyday code but when one is writing rich DSLs that have dependent types, such circular constraints pop-up (eg here). |
You could refactor that to declare const placeOrder: <T extends number[]>(order: Order<T>) => void
type Order<IDs extends number[]> = IDs | {
ids: IDs,
urgent: IDs[number][],
} |
How about this then... declare const placeOrder: <T extends [Order<T[0]>]>(...order: T) => T[0]
type Order<Self> =
Self extends number[] ? number[] :
{ ids: number[]
, urgent?: Self["ids" & keyof Self]
}
declare const orderA: 1
declare const orderB: 2
declare const orderC: 3
let order = placeOrder({ ids: [orderA, orderB], extraMeta: "hello" })
// okay, good
let test: string = order.extraMeta
placeOrder({ ids: [orderA, orderB], urgent: [orderC] })
// nope, `orderC` can't be marked as urgent as it's not being placed I think the thread is missing the point that here order's type is in principle a derivative of it's value, in some cases you can cleverly write types to not have a circular constraint, but that's not always possible and is quite besides the point.
As I said this |
@devanshj It might be a design choice issue, but here is a quick hack for you // First you need this type generic
type Items<Arr extends readonly any[]> =
Arr extends []
? never
: Arr extends readonly [infer first, ...infer rest]
? first | Items<rest>
: never;
type TypeGuard<T extends { ids: readonly any[] }, Ids> = {
// Preserve for future use (might not be needed after 4.9 satisfies operator)
ids: Ids,
urgent: readonly Items<T["ids" & keyof T]>[],
}
type Base = {
ids: readonly number[],
urgent: readonly number[]
}
function constraintNeeded<T extends Base>(arg: T extends TypeGuard<T, infer _> ? T : never) {}
// @ts-expect-error
constraintNeeded({
ids: [1,2,3] as const,
urgent: [4] as const,
})
constraintNeeded({
ids: [1,2,3] as const,
urgent: [1] as const,
}) Note that this makes debugging types much harder (if you don't get it right the first try, type hints will not help you) However, with the new satisfies operator, these type of type guards might be a practical solution |
Thanks but the code that you're trying to fix (the first in OP) already works perfectly fine (and is better than your version): https://tsplay.dev/m3X2bW I just showed this as an example of a (non-immediate) circular constraint TS is fine with. |
Smart, I must have skimmed too hard. Thanks for the example |
I believe it's less about cleverly writing types and simply writing less complex types. For your
In this example, it seems like you're wanting to have custom arbitrary errors rather than an actual constraint which you can already enforce without using circular constraints by adding an intersection to the parameter. I don't think this method has any significant cons and is probably about as good clarity-wise. type Machine = <M>(m: M & InferStringLiteralTuple<ParseMachine<M>>) => M However, because you're using literal types, this won't allow you to throw custom error messages with plain strings like you currently do, you'd have to wrap the message in something else (e.g a single element tuple) or settle for never, but there is already an existing issue for addressing custom error messages which would allow you to throw any message regardless and may also be better suited for this use case. #23689 |
I think this thread has deviated from the main paint so here's a recap to keep the conversation extremely streamlined... Paraphrasing arguments and counter-arguments to keep it short. Argument (from @devanshj in #51011 (comment)): Counter-argument (from @RyanCavanaugh in #51011 (comment)): Argument (from @devanshj in #51011 (comment)): declare const placeOrder: <T extends [Order<T[0]>]>(...order: T) => void
type Order<Self> =
Self extends number[] ? number[] :
{ ids: number[]
, urgent?: Self["ids" & keyof Self]
} Counter-argument (from @Fireboltofdeath in #51011 (comment)): declare const placeOrder: <Ids extends number[]>(order: Order<Ids>) => void
type Order<Ids> =
| Ids
| { ids: Ids
, urgent?: Ids[number][]
} Argument (from @devanshj in #51011 (comment)): declare const placeOrder: <T extends [Order<T[0]>]>(...order: T) => T[0]
type Order<Self> =
Self extends number[] ? number[] :
{ ids: number[]
, urgent?: Self["ids" & keyof Self]
}
let test: string = placeOrder({ ids: [1, 2] as [1, 2], extraMeta: "whatever" }).extraMeta This is the status quo, happy to hear a counter on this. I think this line of discussion would be the most productive, just my two cents. |
This doesn't really require much change besides adding a new generic since you want to infer an entirely new type for Order. The main point of my example was to pass the values you want to constrain against into the order rather than trying to fetch the same values afterwards as I believe that's more idiomatic and simpler than trying to constrain against a derivative of the inferred type variable. declare const placeOrder: <T extends Order<I>, I extends number[]>(order: T & Order<[...I]>) => T
type Order<IDs extends number[]> = IDs | {
ids: IDs,
urgent?: IDs[number][],
} Unfortunately, in this case, |
Well, it's not, here again what you're doing is what I'd call a "workaround" to not have a circular constraint. What I would call simple is embracing the fact that the type of I can increase the requirement even more, how would one refactor this... (I'm increasing the requirement slowly to keep it as minimal as possible.) declare const placeOrder: <T extends [Order<T[0]>]>(...order: T) => T[0]
type Order<Self> =
Self extends number[] ? number[] :
{ ids: number[]
, urgent?: Self["ids" & keyof Self]
, onChange?:
Exclude<keyof Self, "ids" | "urgent" | "onChange"> extends infer MetaKey extends keyof any
? & { [K in MetaKey]?: () => void }
& { [K in Exclude<keyof Self["onChange" & keyof Self], MetaKey>]?: never }
: never
}
declare const orderA: 1
declare const orderB: 2
declare const orderC: 3
placeOrder({
ids: [orderA, orderB],
extraMeta: "hello",
onChange: {
extraMeta: () => {},
// @ts-expect-error
bogusProp: () => {}
}
})
placeOrder({ ids: [orderA, orderB], urgent: [
// @ts-expect-error
orderC
] }) Again I'm just trying to prove this claim of mine...
|
If I'm totally honest the type algebra in TypeScript is hard for me to understand sometimes so I'm not sure if I have a similar use case or not, feel free to send me elsewhere if I'm off the mark with this. Anyway here goes, I'd like to do something like this: interface MyCustomDagDSL {
jobs: Record<string, Job>
}
interface Job {
needs?: JobName[];
action: () => void
}
const dag: MyCustomDagDSL = {
jobs: {
foo: {
action: () => console.log("foo did some work")
},
bar: {
needs: ["foo"], // can I define JobName such that this array is typesafe?
action: () => console.log("bar did some work")
},
},
}); Doing something like: interface MyCustomDagDSL {
jobs: Record<string, Job<MyCustomDagDSL>>
}
interface Job<T extends MyCustomDagDSL> {
needs?: (keyof T["jobs"])[];
action: () => void
} Still just gives me And then the next thing I tried was: interface MyCustomDagDSL {
jobs: Record<keyof MyCustomDagDSL["jobs"], Job<MyCustomDagDSL>>
} Which gives: interface DagDSL<Self> {
jobs: Record<keyof Self["jobs" & keyof Self], Job<DagDSL<Self>>>;
}
interface Job<T extends DagDSL<T>> {
needs?: (keyof T["jobs"])[];
action: () => void;
}
class Dagfile<T extends DagDSL<T>> {
constructor(d: T) {
}
}
new Dagfile({
jobs: {
foo: {
action: () => console.log("foo did some work"),
},
bar: {
needs: ["foo"], // yay this is now type safe
action: () => console.log("bar did some work"),
},
},
}); But then I wanted to have 2nd level in my DSL, like this: interface DagDSL<Self> {
jobs: Record<keyof Self["jobs" & keyof Self], Job<DagDSL<Self>>>;
}
interface Job<T extends DagDSL<T>> {
needs?: (keyof T["jobs"])[];
steps: Record<keyof T["steps" & keyof T], Step<Job<DagDSL<T>>>>;
}
interface Step<T extends Job<DagDSL<T>>> {
needs?: (keyof T["steps"])[];
action: () => void;
}
class Dagfile<T extends DagDSL<T>> {
constructor(d: T) {
}
}
new Dagfile({
jobs: {
foo: {
steps: {
step1: {
action: () => console.log("foo did some work"),
},
step2: {
needs: ["step1"], // not type-safe :(
action: () => console.log("foo did some more work"),
},
},
},
bar: {
needs: ["foo"],
steps: {
step1: {
action: () => console.log("bar did some work"),
},
},
},
},
}); |
I guess what you want is something like this... class Dagfile<T extends DagDSL<T>>{
constructor(d: T){}
}
new Dagfile({
jobs: {
foo: {
steps: {
step1: {
action: () => console.log("foo did some work"),
},
step2: {
needs: ["step1"],
action: () => console.log("foo did some more work"),
},
},
},
bar: {
needs: ["foo"],
steps: {
step1: {
action: () => console.log("bar did some work"),
},
},
},
},
});
type DagDSL<Self> =
{ jobs:
{ [J in keyof Prop<Self, "jobs">]:
{ needs?: Exclude<keyof Prop<Self, "jobs">, J>[]
, steps:
{ [S in keyof Prop<Prop<Prop<Self, "jobs">, J>, "steps">]:
{ needs?: Exclude<keyof Prop<Prop<Prop<Self, "jobs">, J>, "steps">, S>[]
, action: () => void
}
}
}
}
}
type Prop<T, K> = K extends keyof T ? T[K] : never |
Suggestion
π Search Terms
Circular constraints, "Type parameter 'T' has a circular constraint.(2313)"
β Viability Checklist
My suggestion meets these guidelines:
β Suggestion
Often constraints are truly circular, that is to say we want type-checking an parameter based on itself, for instance consider this...
Here the circular constraint
T extends Order<T>
compiles because it's not immediately circular, but in case of immediately circular the compiler complaints and doesn't allow compiling it...As a workaround we could make the circular constraint non-immediate by using a variadic argument...
But this makes the type unnecessarily complicated, in some scenarios even more complicated...
This could have simply been the following if TS allowed circular constraints...
So the feature request is to allow writing circular constraints.
The text was updated successfully, but these errors were encountered: