-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
The Meta Type System — A Proposal #55521
Comments
Since it's two weeks and nobody has said anything yet, thanks for doing all this work and writing up a detailed proposal! |
Would it be possible to transpile the functionality contained in this proposal down to currently-supported TS language features? I suspect that giving people a chance to play with the new system might be your best shot at getting it adopted. |
Thank you! I really did work on it quite a lot, though I think I completely failed at actually explaining how the system works. Transpiling it is a great idea! I think it's perfectly doable. Every meta type can just be expanded into separate type parameters. I've moved to working on other stuff for now, but I'll keep it in mind when I get back to this. |
I'm not really understanding what's being gained here vs regular lookup types. Taking this example 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!
} Could be written today as type MyMetaType<T> = {
Type: T
}
function goMetaTypes<Meta extends MyMetaType<unknown>>() {
// let y: Meta
// OK, but not problematically so
let x: Meta["Type"]
// It worked!
// How about...
const y = (x: Meta["Type"]) => "hello world"
// That's pretty cool! Having one member is kind of useless, though
// Bye!
} A reasonable complaint here would be that it's awkward to scale up type MyMetaType<T1, T2, T3> = {
Alpha: T1,
Beta: T2,
Gamma: T3
}
function goMetaTypes<Meta extends MyMetaType<unknown, unknown, unknown>>() {
let x: Meta["Alpha"]
const y = (x: Meta["Beta"]) => "hello world"
const z: Meta["Gamma"] = null as never;
} It's still refactorable into something that seems to act just like a meta type: type MyMetaTypeConstraint = {
Alpha: unknown;
Beta: unknown;
Gamma: unknown
}
function goMetaTypes<Meta extends MyMetaTypeConstraint>() {
let x: Meta["Alpha"] = null as never;
const y = (x: Meta["Beta"]) => "hello world"
const z: Meta["Gamma"] = null as never;
return { x, y, z };
}
// Works
const obj = goMetaTypes<{Alpha: string, Beta: number, Gamma: boolean}>() On net something needs to provide a lot more concrete value than sugar with a slightly different flavor over what's available today. Creating a totally new "kind" of type that is basically what's already doable with lookup types doesn't seem to meet the bar -- something this complex should be solving novel problems with more conciseness and clarity IMO. For example, I don't have any idea how I'd document when to use a 'meta' type vs a standard lookup type -- the latter seems dominating in terms of conceptual clarity in all scenarios. The same goes for this example: 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
} What's the value-add here over the lookup-based equivalent? What new scenarios are unlocked? |
First of all, thank you for taking the time to read and reply to my proposal! I was very uncertain how to write it and which parts I should emphasize. Your response really helps. The meta type system has lots of benefits over existing solutions. I’ll also answer the question of when they should be used over lookup types. Generic lookups are unsoundLookups on type parameters are deeply unsound in normal contexts, including the example you mentioned. This is because the type value of the expression Playground has some more examples type Box<T = unknown> = {
Inner: T;
};
{
const f = <B extends Box>(): B["Inner"] => {
// ↓ compiler thinks that B["Inner"] is `unknown` here
"hello world" satisfies B["Inner"];
new Error("blah blah") satisfies B["Inner"];
return 42;
};
const result = f<{ Inner: never }>() satisfies never;
// ↑ we produce never instead
} If the computation wasn’t deferred, In other cases this kind of code can also result in unexpectedly losing type information. In particular, when we have a constrained type variable, the lookup type is different from the actual type of the property being looked up. This is something that doesn’t happen if we use type parameters properly, instead of through object types like this. // lookup is typed number:
const f = <B extends Box<number>>(b: B) => b.Inner;
// lookup is typed N:
const f2 = <N extends number>(b: Box<N>) => b.Inner;
// in any case, lookups are never typed T["SomeKey"].
// @ts-expect-error this is typed `number`
f({ Inner: 1 }) satisfies 1;
// This is correctly typed `1`
f2({ Inner: 1 }) satisfies 1; A third approach involves using an inferred type. This is done using a pattern you sometimes see in the wild. Namely, an accessor type like this: type GetInner<B extends Box<unknown>> = B extends { Inner: infer X } ? X : never While you might think that const f = <B extends Box>(box: B): GetInner<B> => {
// @ts-expect-error doesn't work
box.Inner satisfies GetInner<B>;
// @ts-expect-error doesn't work either
box.Inner as Box["Inner"] satisfies GetInner<B>;
// We have to resort to just asserting it
const inner = box.Inner as GetInner<B>;
return box.Inner as any
} Essentially, here it’s treated as an existential type which is a subtype of This is actually sound, as TakeawayThere are only two ways to treat the expression
When you use the expression Inexpressible signaturesOne of the goals of meta types is to be able to express generic signatures as reusable entities with their own structure. Existing patterns can only describe a strict subset of generic signatures. Specifically:
Let’s look at these separately. Mutual constraintsType constraints that involve other type parameters can’t be emulated using a regular subtype constraint involving a data type, even in principle. We can show this using a proof that will shed more light on the pattern’s limitations. type Generic<Alpha extends string, Beta extends Alpha> = {
alpha: Alpha
beta: Beta
} Imagine we had some advanced object type // The signature <Alpha, Beta extends Alpha>
// Is emulated with the aid of some AdvancedObject:
type Generic2<T extends AdvancedObject> = {
alpha: T["Alpha"]
beta: T["Beta"]
} Therefore: type UnknownUnknown = {Alpha: unknown, Beta: unknown}
Generic<unknown, unknown> ≡ Generic2<UnknownUnknown> But if so: UnknownUnknown extends AdvancedObject Any supertype of type MetaType := <
Alpha: type
Beta extends Alpha
> Why meta types workThe key part in my example of the previous section was applying the assignability relation defined over regular types. It shows that the relation is not specific enough to describe the Meta types constrain types more directly, to the full extent that's allowed by TypeScript's generics system, and with the potential to do even more in the future. This is only possible because they aren't object types and work using different rules. Meta types are higher-order types, or the types of types, and work like Haskell's higher-kinded types feature.
Here is an illustration of meta types, meta objects, and how they relate to regular types and values: Inference qualifiersThe lookup pattern doesn’t support inference qualifiers, such as default type parameter values. In principle we could add these things to object types and have These aren’t actually part of the type system, so it’s not mathematically impossible or something. Maybe it would look something like this: type Example<T> = {}
type WithDefaults = {
A: unknown = "hello world",
B: unknown = number
}
function example<T extends WithDefaults>() {} While this would be useful, and you could see this information being used to construct subtypes of Namely, we are taking a value type and attaching to it information that’s not actually related to values of that type or any process of type checking. This is information that’s invisible and irrelevant to the assignability relation, which is supposed to determine equivalence between types. We are using this one structure for a completely different purpose that it’s just unsuited for. Comparisons to #54254My proposal is only superficially similar to #54254. Specifically:
That said, the features I describe in my proposal don’t introduce any new value types – in their current form, meta types can always be unpacked into giant lists of type parameters with no meta types, while also copying implicit structure to the point of reference. This means that it’s possible to write a compiler plugin or something similar that will transpile meta types to valid TypeScript code that type checks the same way. This quality disappears if more features are introduced. For instance, I’ve looked at disjunction meta types A few more thoughtsThe Fastify type definitions are incredibly complicated and can’t be reproduced using lookups due to their many limitations. The idea that lookup types solve all of these issues is a bit silly, since lookup types have been around for a long time and yet these problems still exist. In fact, the Fastify type definitions actually do use them in specific cases where their limitations allow it. Hell, my refactoring which uses meta types also uses them a few times. With regard to the following question:
Trying to work with type parameters through data types is inherently limited and deeply unsound. |
So I’ve figured out a way to do what I was on about in a somewhat different way, and without such a big extension. We still have meta types, but now they’re used to constrain regular object types. They use the Here is an example of this meta type in action, showing what I said was impossible using regular object type constraints: type type MetaType2 = {
SomeType: infer X,
OtherType: infer Y extends X
}
function something<Types: MetaType2>(): Types["SomeType"] {}
something<{SomeType: number, OtherType: 1}() A meta type constraint becomes a new kind of constraint on regular types, instead of being this parallel type system, and works together with lookup types. It creates a kind of “type shape” that can match types and express things like circular constraints between the types of different members. Taking this new form, meta types lose the correspondence with the generics system and namespaces, but they gain a correspondence with object types. Although the Rules for MetaType2This new form of meta types admits several kinds of constraints, all on object members. An unconstrained
|
You can use this system to talk about other types really. It lets you create types that use the full power of generic signatures. For instance, the following meta type is equivalent to the constraint type type StringValues = infer X extends Record<keyof X, string>
declare function blah<X: StringValues>()
blah<{a: "a", b: "b"}>() You can also nest these constraints: type type Type1 = {
a: infer A: {
b: infer B
}
}
declare function f<A: Type1>(): A["a"]["b"]
f<{
a: {
b: number
}
}>() Honestly I kind of feel like I've come full circle, because this looks very similar to what I had a few months ago. |
📜 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:
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?
📌 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.
where
clauseThere 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:
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:
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:
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:
We could access the properties of the object we described previously, via:
We could also access the members of that strange, imaginary thing using the following syntax:
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: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:
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: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.
We could also make up a shorthand that goes like this:
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:
We can call the first function with
Meanwhile, we can call the 2nd function with all the instantiations of that generic signature. We can write these as meta objects:
These meta objects are instances of a meta type written:
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:Object types annotate object members with other object types. Meanwhile, meta types annotate object members with meta types:
Here are a few other meta types:
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.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:
What’s more, subtype constraints can refer to other members in the constraint, like this:
And instances of that meta type would have to fulfill both constraints:
Oh, this is how you declare meta types:
🛠️ 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:
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:
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.
Thank you, code inside the function. Now we will call it repeatedly, kind of like in a Black Mirror episode:
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:
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 phrasePredicate
as implicit structure, it would look like this:When determining if a meta object is an instance of a meta type, implicit structure doesn’t matter, so the following code works:
🧠 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
🖼️ 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:
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.
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!
The text was updated successfully, but these errors were encountered: