Trying To Understand Copilot's Type Spaghetti
The other day this snippet of Typescript generated by Github copilot was floating around:
type MappedParameterTypes<T extends Parameter[]> = {
// Check if the parameter has an 'enum' defined
[P in T[number] as P["name"]]: P extends {enum: Array<infer E> }
? E extends string // Ensure the enum values are strings
? P["required"] extends false // Check if the parameter is optional
? E | undefined // If so, include 'undefined' in the type
: E // Othewise use the enum type directly
: never // This case should not occur since 'enum' implies string values
: // Handle parameters defined as 'object' with specified attributes
P extends {type: "object"; attributes: infer Attributes }
? Attributes extends Parameter[]
? MappedParameterTypes<Attributes> // Recursively map the attributes of the object
: never // If 'attributes' is not an array of Parameters, this is invalid
: // Handle parameters defined as 'object[]' without specified attributes
P extends { type: "object[]"; attributes?: never }
? any[] // Default to 'any[]' for arrays of objects without specific attributes
:
P extends {type: "object[]"; attributes: infer Attributes }
? Attributes extends Parameter[]
? MappedParameterTypes<Attributes>[] // Recursively map each object in the array
: any[] // Default to 'any[]' if attributes are not properly defined
: // Handle all other parameter types
P["required"] extends false
? // Include 'undefined' for optional parameters
TypeMap[P["type"] extends keyof TypeMap ? P["type"] : "string"] | undefined
: // Use the direct mapping from 'TypeMap' for the parameter's type
TypeMap[P["type"] extends keyof TypeMap ? P["type"] : "string"];
};
This is some type-level programming that can transform an array of field definitions into a type. The above is the original code, and I was able to fill in the blanks for TypeMap
and Parameter
with the following:
interface Parameter {
name: string,
required?: false,
type?: keyof TypeMap
}
interface TypeMap {
number: number,
boolean: boolean,
string: string,
}
The first question is how do you use something like MappedParameterTypes
? I've definitely seen this code in many projects, especially those with bespoke ORMs. The basic idea is to map an object configuration to some type.
const parameters = [
{
name: "foo",
type: "number"
} as const,
{
name: "bar",
required: false,
type: "string"
} as const,
{
name: "baz",
enum: ["b", "c"] as const
} as const,
];
const mappedObjs: MappedParameterTypes<typeof parameters>[] = [
{
foo: 1,
bar: "hi",
baz: "b"
},
{
foo: 2,
bar: undefined,
baz: "c"
}
]
I have an object definition, with foo
being a number, bar
being a string, and baz
being an enum. I have added const
everywhere to "make sure" that Typescript doesn't lose track of the object literals in parameters
(a constant thing to take care of in type-level programming in Typescript).
But the end result here is that mappedObjs
is of the "type" {foo: number, bar: string, baz: "b" | "c"}[]
. We were able to take our parameter structure and transform that into a type.
This is a very common task in Typescript, and can catch a lot of bugs! If I didn't include foo
or bar
in the objects, then there would be an error. But the code as-written still can be fragile.
For example, in the above, I can set baz
to "a"
or "d"
and won't get an error, despite me having created baz
as an enum type! I can't set a string, but at some point in the type unification process, Typescript decided that ["b", "c"] as const
is not ("b" | "c")[]
but instead is just string[]
.
However, if I had written:
{
name: "baz",
enum: ["b", "c"] as ("b" | "c")[]
}
then this problem disappears. Type programming is a fidget-y process in general.
Is there a cleaner version of this code?
This current type is, quite honestly, not the worst kind of type-level programming out there. There's comments, and the indentation is being used to follow the ternary flow relatvely well. But errors are a bit illegible because everything is object literals.
The simplest thing you can do is factor out some of the type logic into its own intermediate types.
interface EnumParameter<E> extends Parameter {
enum: Array<E>
}
type EnumParameterValue<P extends EnumParameter<E>, E> =
E extends string // Ensure the enum values are strings
? P["required"] extends false // Check if the parameter is optional
? E | undefined // If so, include 'undefined' in the type
: E // Othewise use the enum type directly
: never // This case should not occur since 'enum' implies string values
type MappedParameterTypes<T extends Parameter[]> = {
// Check if the parameter has an 'enum' defined
[P in T[number] as P["name"]]:
P extends EnumParameter<infer E>
? EnumParameterValue<P, E>
: // Handle parameters defined as 'object' with specified attributes
...
}
here I factored out the enum parameter shape, as well as the logic to extract the array.
Another option would involve factoring out the "required" check, and actually including the string
check higher up directly, avoiding the need for never
.
interface EnumParameter<E extends string> extends Parameter {
enum: Array<E>
}
type MaybeRequired<V, P extends Parameter> =
P["required"] extends false ? V | undefined : V
type MappedParameterTypes<T extends Parameter[]> = {
// Check if the parameter has an 'enum' defined
[P in T[number] as P["name"]]:
P extends EnumParameter<infer E>
? MaybeRequired<E, P>
: ...
}
The nice thing with this kind of code is that later on, when you're writing your parameter values, you could make sure you're not making a mistake by actually annotating the types. Instead of seeing errors on the mapped types, you'll see the errors (correctly) shown on the parametesr.
const ep: EnumParameter<"b" | "c"> = {
name: "bar",
enum: ["b", "c"]
}
This might seem silly on smaller constants like this, but is invaluable when composing code, to keep track of where you're at.
Ultimately this sort of thing is like any one-liner, extremely subjective in terms of readability. I was able to walk through the thing and understand how it works. But when building these sorts of one-liners and not factoring things out you can easily miss the forest for the trees.
For example:
P extends {type: "object[]"; attributes: infer Attributes }
? Attributes extends Parameter[]
? MappedParameterTypes<Attributes> // Recursively map each object in the array
: any[] // Default to 'any[]' if attributes are not properly defined
Why go through the trouble of writing a type mapping system, only for your code to be able to introduce any[]
because one of your attributes was slightly mistyped? This sort of code will lead to type errors being reported in unexpected places (or not at all), instead of where the error actually came from.
I similary find that the P["requires"] extends false
check being placed somewhat haphazardly leads to weird usability issues. I can make an enum or a string or number field optional, but I can't make an object field optional?
Losing Information In Type-Level Programming
I lied a bit above. EnumParameter< "b" | "c">
causes problems downstream, because by annotating the object, we lose whether the element is required or not. We also lose the name of the parameter.
Typescript is powerful enough that you can get away with a lot by just annotating a lot of constant dictionary maps. But really the pedantic way to do this ends up being things like the following:
interface EnumParameter<
E extends string,
Name extends string,
Required extends boolean
> extends Parameter<Name,Required> {
...
}
If you want to access information about a type, generally speaking you will want it to be present somewhere in your generic signature. Otherwise, type annotations will affect your results. Best case, you "merely" have some ambiguous type errors. But worst case, you end up with some implicit any
s floating around.
The beauty of the original one liner, is that the spaghetti-code is ammenable to good UX in Typescript! The world is a cruel place, where annotating and trying to make things explicit can make your life harder. Beauty might not be a word ammenable to Copilot's "quantum superposition of a million code repositories". But it somehow works well enough in many circumstances.
But if you want something that is easy to maintain, that unfortunately often requires a lot of pedantic work and putting things into generic types.
A core takeaway with type-level programming, at least in Typescript: If you want certain information to be used, having it be available in a generic type signature will avoid code that "accidentally" works thanks to the type inference system.
Change The Shape Of Your Problem If You Can
If you're intent on doing this sort of type-level thing, laaning into what is easy can save you a lot of time. Instead of using arrays of parameters, maps of parameters would reduce the cost of a pedantic type.
const parameters = {
"foo": stringParam,
"bar": optionalNumberParam,
"baz": myEnumParam
}
When you have this sort of structure, you can more easily re-use constants defined elsewhere. This means that if you do end up with pedantic signatures, you're not paying a huge cost for them. And if you have pedantic type signatures, your errors might also be pedantic, but they are more likely to be where the problem is, and not downstream.
It's very easy to get asymptotically close to something that works. But often times, you might be one slight transformation from something that is much more straightforward. For example, here, decoupling the naming and the requiredness from the type development might lead to more lines of codes, but that are easier to grok.
But I have to be honest: I tried a couple "easy win" refactors, and often would hit some other problem. Sometimes the clean answer requires some good inspiration.
If you are curious about diving deeper into this sort of type-level programming, I highly recommend Execute Program's Advanced Typescript Course. It offers a very detailed look into how you can accomplish very powerful things, with more detail than you'll find simply looking at the Typescript Handbook.