Description
openedon Aug 26, 2023
📜 Introduction
It’s common for mainstream libraries to have unreadable generic signatures and instantiations. We see this everywhere — client or server-side, libraries for any purpose or goal. Here is an example from fastify, just the highlights:
export interface FastifyReply<
RawServer extends RawServerBase = RawServerDefault,
RawRequest extends RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
RawReply extends RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
ContextConfig = ContextConfigDefault,
SchemaCompiler extends FastifySchema = FastifySchema,
TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault,
ReplyType extends FastifyReplyType = ResolveFastifyReplyType<TypeProvider, SchemaCompiler, RouteGeneric>
> {
id: string;
params: RequestType['params']; // deferred inference
raw: RawRequest;
query: RequestType['query'];
headers: RawRequest['headers'] & RequestType['headers'];
log: Logger;
server: FastifyInstance;
body: RequestType['body'];
context: FastifyContext<ContextConfig>;
routeConfig: FastifyContext<ContextConfig>['config'];
routeSchema: FastifySchema
}
What stands out is not only the length, but also the amount of repetition in this one type. And this is but one of a massive family of types, copying the same signatures and the same constraints.
This is not new, and in fact I’m sure we’ve all encountered these kinds of types ourselves. Maybe we even had to write them. While there are some methods of managing them, such as the “generic bag” approach (see #54254), they are far from universally successful, and in fact come with their own problems.
Let’s start by recognizing that this is essentially a code duplication problem. If the code above was actually one big function, with RawServer
, RawRequest
, and so on being parameters we’d know exactly what to do. Define a few objects, a dash of composition here or there. And bam we just turned a monster into something humans can comprehend. There is a reason why OOP was so successful, after all.
But this isn’t runtime code — it’s type parameters. You can’t make objects out of those.
What if you could, though? How would that even look like?
What if you could apply TypeScript’s structural type system to itself?
// Used to be FastifyRequest<...9>
// Uses meta type definitions from https://gist.github.com/GregRos/c250502c88e3babb9f212be04929c2c6
export interface FastifyRequest<Request: MRequest> {
id: any
params: Request.Params
raw: Request.Core.Raw.Request
query: Request.Query
headers: Request.Headers
readonly req: Request.Req
log: Request.Core.Logger
body: Request.Body
}
📌 Specific issues
This proposal introduces powerful abstractions dealing with type parameters. These abstractions let us address multiple feature requests throughout the language’s history.
Here is an incomplete list.
Req | Name | How it’s addressed |
---|---|---|
📌 #54254 | Named type parameters | Meta types, there is also a short hand |
📌 #26242 | Partial generic parameterization | Meta object inference |
📌 #17588 | Associated types | Implicit structure, but classes don’t support it directly |
📌 #14662 | Generic type display | Individual signatures become less complicated |
📌 #42388 | where clause |
Solves the same issues, including “scoped aliases” |
There is also a natural extension to HKTs (#1213 among others), where we allow meta objects to contain generic types. However, that’s out of scope for now. I honestly feel the feature is complicated enough as it is.
Anyway, that’s enough for the intro.
🌌 The Meta Type System
The meta type system has two components:
- The meta object, a type-level structure that embeds types like normal objects embed values.
- The meta type, which is the type of a meta object.
This system allows us to view problems involving type parameters as problems involving object structure.
The meta type system is a unique extension designed specifically for TypeScript, based on existing TypeScript concepts, rules, and conventions. While it resembles features found in other languages, it’s very much its own thing, and it wouldn’t make sense anywhere else.
Because of that, we need to take things slow and with lots of silly examples. If you want to see something that looks like actual usage, take a look at this partial refactoring of Fastify’s type definitions.
🧱 Meta objects
Meta objects aren't useful by themselves, but they form the building blocks of the meta type system, so it's important we understand them.
We'll start with an exercise. Let's say we have this function, which returns the parameters it was invoked with as an object:
function exercise(a: number, b: string, c: object) {
return {a, b, c}
}
const invocationObject = exercise(1, "hello", {})
console.log(invocationObject)
What we get is a kind of map of parameter names to values.
Now, let's imagine we could do the same with a generic instantiation of a type or function:
type Generic<A, B> = 0
type Instantiated = Generic<string, number>
// ↑ instantiation
That is, match the name of every type parameter with the type it takes. But unlike in the signature, we’re not going to care about the order, just the names.
The result would be something like this:
// Here, we use := as a definitional operator,
// like = or : in other contexts.
<
A := string,
B := number
>
We could access the properties of the object we described previously, via:
{ a: 1, b: "hello", c: {} }.b
We could also access the members of that strange, imaginary thing using the following syntax:
<
A := string,
B := number
>.A
Which would get us a type, string
. This is the behavior we see in namespaces, except that we can't really make a namespace expression. We can reference one though:
namespace Example {
export type A = string
export type B = number
}
let x: Example.A
The object-like structure is a meta object. Where normal objects embed values like 5
and “hello world”
, these meta objects embed types like:
string
{ id: number, name: string }
These types don’t form part of the structure of the meta object, but are treated as scalars embedded in its structure, just like how type declarations work in a namespace.
We want meta objects to be based on regular objects and preserve as much of their behavior as possible. Regular objects can be declared, imported, and exported — so the same applies to meta objects:
export object Foobar := <
Foo := number,
Bar := { id: number }
>
Since regular objects can be nested, so can meta objects. Note that the object type {…}
doesn’t indicate nesting – instead, it indicates an embedded type (that happens to be an object). Instead, nesting looks like this:
<
Foo := <
Bar := string
>,
Baz := object
>
Like all TypeScript entities, meta objects are purely structural. They just have a new form of structure – meta structure, which is what the <…>
bits are called. Two meta objects with the same meta structure (including embedding the same types, up to equivalence) are equivalent.
Regular objects also tell us that the ordering of the keys doesn’t matter, so the same applies here.
Because we originally defined meta objects as floating generic instantiations, it makes sense to define a spread operator for type parameters that applies a meta object to a generic signature.
This operator works via key-value semantics instead of sequential semantics, which is definitely unusual but it’s also well-defined, since type parameters will always have different names.
type Generic<A, B> = 0
object Instantiation := <
B := number
A := string,
>
type Instantiated = Generic<...Instantiation>
We could also make up a shorthand that goes like this:
type Full = Generic<...< B := number, A := string >>
type ShortHand = Generic<B := number, A := string>
Where we let the <...>
describe a meta object, and apply it directly to the generic signature. This allows us not to care about the order. This basically results in a different calling convention, and mixing the two is not allowed.
🌌 Meta types
We’re going to start by taking a look at these two functions:
declare function normal(obj: {a: number; b: number; c: number})
declare function meta<A, B, C>()
We can call the first function with
{ a: 1, b: 2, c: 3 }
{ a: 55, b: 15, c: 2 }
...
Meanwhile, we can call the 2nd function with all the instantiations of that generic signature. We can write these as meta objects:
<
A := number,
B := string,
C := null
>
<
A := {id: number},
B := undefined,
C := never
>
<
A := symbol,
B := string,
C := ArrayBuffer
>
These meta objects are instances of a meta type written:
<
A: type,
B: type,
C: type
>
A meta type is like a floating generic signature phrased as an object, where each generic parameter is seen as a named member of that object. It defines a structural template that some meta objects match, while others don’t. Again, we ignore the order, because we’re phrasing things as objects and that’s just how objects work.
The : type
component is a meta annotation that just says the member is a type. We need it because meta types must describe all possible meta objects, and some meta objects contain other meta objects as values. To describe these, we do the same thing that happens in object types such as:
{
a: { b: string }
}
Object types annotate object members with other object types. Meanwhile, meta types annotate object members with meta types:
<
A: <
B: type
>
>
Here are a few other meta types:
<
A: <
B: type;
C: type
>
>
<
Key: type;
Value: type
>
< >
Generic signatures can impose constraints on their types using extends
. Well, meta types can do that too. If you set a subtype constraint, the member is guaranteed to be a type, so the : type
component is just inferred.
<
A extends string;
B extends number;
C extends object
>
This works just like a subtype constraint in a generic signature. This only makes sense for type members, so the : type
meta annotation can be inferred.
As you can see, you write lots of possible meta types, and you can use both constraints in the same meta type on different members:
<
A extends string,
B: < X: type >
>
What’s more, subtype constraints can refer to other members in the constraint, like this:
<
A extends string;
B extends A
>
And instances of that meta type would have to fulfill both constraints:
<
A := string,
B := "hello world"
>
<
A := "a" | "b",
B := "a"
>
<
A := never,
B := never
>
Oh, this is how you declare meta types:
export type SoMeta := <
A: type
>
export MoreMeta := <
Than: SoMeta
>
🛠️ Meta types in code
To be used in actual code, a meta type needs to be applied to annotate something. There are three somethings that qualify:
- A generic parameter, like in a function or a generic type.
- A namespace
- A module
Let’s look at the first one, because that’s how they’re going to be used most of the time. We learned in the last section that meta types correspond to generic signatures, and we also learned that they can use meta annotations.
This means that we can also use meta annotations in generic signatures. We apply them on generic parameters, and it marks that parameter as being a meta object parameter. It means that we expected that parameter to be a meta object.
Here is how it looks:
export type MyMetaType := <
Type: type
>
declare function hasMetas<X: MyMetaType>(): void
hasMetas<
< Type := string >
>(): void
That’s kind of weird, but not that weird. I mean, I bet there’s even weirder stuff in the code you write.
Let’s take a look at what code inside the function sees when we do this.
function goMetaTypes<Meta: MyMetaType>() {
// Hi, it's me, the code inside the function!
// I want to try something...
// let y: Meta
// ^ ERROR: Meta is not a data type
// Woops! That didn't work. I guess Meta is a meta object
// which... is kind of like a namespace! Does that mean...
let x: Meta.Type
// It worked! Thankfully we have strict set to false.
// How about...
const y = (x: Meta.Type) => "hello world"
// That's pretty cool! Having one member is kind of useless, though
// Bye!
}
Thank you, code inside the function. Now we will call it repeatedly, kind of like in a Black Mirror episode:
goMetaTypes< < Type := string >>()
goMetaTypes< < Type := number >>()
// goMetaTypes < < Type := <> >()
// ^ ERROR: Expected a data type
That last one was on purpose.
What the code inside the function didn’t know is that meta types can do something that other types can’t. It’s called implicit structure!
💡Implicit structure
Implicit structure is structure that belongs to a meta type. This isn’t that unusual when you look at languages like C#, where methods are defined on types, objects have types and thereby access to the functionality those types define in the form of methods and other stuff.
In TypeScript, though, it’s totally wild. Types, after all, are totally separate from the runtime world. The instance of a type is a runtime value, which has no idea about the type. It has its own structure and that’s about it.
However, things are different when it comes to meta types and meta objects. Both of them are part of the type system, and meta objects are usually passed together with a meta type annotation. Because of this, implicit structure can actually work, as long as it only consists of type definitions.
Anyway, here is how it looks like:
type HasImplicit := <
A: type
ImImplicit := (x: A) => boolean
>
Implicit structure is defined using the same operator used for meta objects, and means that a member defined like this is fixed, rather than variable like a type member.
Implicit structure is extremely useful for phrasing utility types that are common in highly generic code. For example, let’s say that you you have an Element
meta type, and your code uses its predicate a lot.
While one way would have you write Predicate<Element>
, if we phrase Predicate
as implicit structure, it would look like this:
type Element := <
Value: type
Predicate := (x: T) => boolean
>
When determining if a meta object is an instance of a meta type, implicit structure doesn’t matter, so the following code works:
class Collection<E: Element> {
constructor(private _items: E.Value[]) {}
filter(predicate: E.Predicate) {
return new Collection<E>(this._items.filter(predicate))
}
push(value: E.Value) {
this._items.push(value)
}
}
const collection = new Collection<<E := number>>([1])
collection.push(2)
const filtered = collection.filter(x => x % 2 === 0)
🧠 Inference
Because meta types don’t have order to their members, you can specify members you want and have inference complete the others for you. In principle, at least.
Let’s take a look at how this works. Let’s say that you have a class, where most of the parameters are inferable except one in the middle. Then you can use the meta object shorthand to omit the parameters that can be inferred, allowing you to specify only the one that can’t
class Thing<A, B, C> {
constructor(a: A, c: C) {}
}
new Thing<B := number>("hello", "world")
🖼️ Usage Example
Here is an example of how meta types can be used to express highly generic code.
Just like what I described in the intro, we’re basically going to be refactoring procedural/modular programming code into object-oriented code. As such, the same principles apply:
- Identify groups of parameters that often occur together and organize them into meta types.
- See if there are any derived types using those parameters and express them using implicit structure.
- Use composition to extend meta types defined in (1) as necessary
The resulting types should follow the same guidelines as for function signatures. As few type parameters as possible, and in this case try for either 1 or 2.
💖 THANK YOU 💖
Thank you for reading this! Please comment and leave feedback.
- Does it seem useful?
- Should I elaborate on something?
- Should I provide more examples?
- Have I missed anything crucial?
I feel like I'm onto something, but I've been working on this stuff for a while, almost entirely by myself, and I need some help!