Skip to content
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

Open
GregRos opened this issue Aug 26, 2023 · 7 comments
Open

The Meta Type System — A Proposal #55521

GregRos opened this issue Aug 26, 2023 · 7 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@GregRos
Copy link

GregRos commented 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:

  1. A generic parameter, like in a function or a generic type.
  2. A namespace
  3. 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:

  1. Identify groups of parameters that often occur together and organize them into meta types.
  2. See if there are any derived types using those parameters and express them using implicit structure.
  3. 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!

@conartist6
Copy link

conartist6 commented Sep 9, 2023

Since it's two weeks and nobody has said anything yet, thanks for doing all this work and writing up a detailed proposal!

@conartist6
Copy link

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.

@GregRos
Copy link
Author

GregRos commented Sep 10, 2023

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.

@RyanCavanaugh
Copy link
Member

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 MyMetaType, which is I think what you were getting at with the FastifyReply example. So what's really going on here is a sugary transform from a list of type parameters to a key/value setup, which is what #54254 does more explicitly. Creating a separate generic syntax in the sort of uncanny valley to accomplish this feels off to me. If we think about a higher-arity MyMetaType that's visibly awkwardly large:

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?

@GregRos
Copy link
Author

GregRos commented Sep 16, 2023

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 unsound

Lookups on type parameters are deeply unsound in normal contexts, including the example you mentioned.

This is because the type value of the expression T["SomeKey"] depends on where it’s computed, but because computation is deferred this ends up causing the compiler to treat incompatible types as compatible.

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, B["Inner"] would just resolve to unknown , making it sound but useless for this purpose. Using lookups in this way basically turns off type checking.

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 GetInner<B> behaves like b.Inner or B["Inner"], in fact it behaves very differently.

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 unknown – that is, any possible type ranging from never to unknown itself. The only thing it can be compared to is an existential type that’s the result of the same expression, and so almost nothing is assignable to it.

This is actually sound, as B could just be never or Box<never>, resulting in GetInner<B> being never as well. Meaning nothing should be assignable to it.

Takeaway

There are only two ways to treat the expression B["Inner"] soundly. These methods are used by different parts of the language.

  1. Treating it as unknown, which is how b.Inner is treated.
  2. Treating it as an opaque and incomparable existential, which is how GetInner<B> treats it.

When you use the expression B["Inner"] the compiler cheats by doing both at the same time. This effectively turns off type checking.

Inexpressible signatures

One 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:

  1. Where no constraint involves another type parameter.
  2. There are no inference qualifiers (const or defaults)

Let’s look at these separately.

Mutual constraints

Type 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 AdvancedObject that, through a single subtype constraint, could describe the type Generic<...>

// 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 UnknownUnknown can’t be constrained at all, violating our assumptions.
Meanwhile, meta types can describe that type of signature easily:

type MetaType := <
	Alpha: type
	Beta extends Alpha
>

Why meta types work

The 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 AlphaBeta type.

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.

  • A Kind is the type of a type constructor, which can be viewed as a type-level function that takes a type as an input and returns one as an output.
  • A meta types is the type of a meta object, which is a type-level object that has values which are types.

Here is an illustration of meta types, meta objects, and how they relate to regular types and values:

Untitled design

Inference qualifiers

The 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 extends also cover them, as a tacked on responsibility.

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 WithDefaults in ambiguous situations, it actually highlights an issue with any scheme where regular types are used in this way.

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 #54254

My proposal is only superficially similar to #54254. Specifically:

  1. Meta types are reusable declared entities with their own structure. 54254 doesn’t provide reusable constructs.
  2. They simplify generic signatures; signatures in 54254 are just as long.
  3. They can be extended through composition and via a & operator. 54254 doesn’t do this.
  4. They provide a way of expressing reusable associated types through implicit structure.

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 Meta1 | Meta2, and these implement #39526. The result can’t be described by the type system. I haven’t brought it up because the proposal is complicated enough as it is.

A few more thoughts

The 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:

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.

  1. You would use meta types when working with type parameters.
  2. You would use lookup types if you want to get the computed type of a data property in an object type. Which is what they’re supposed to be for.

Trying to work with type parameters through data types is inherently limited and deeply unsound.

@GregRos
Copy link
Author

GregRos commented Oct 3, 2023

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 infer grammar to capture the types of members.

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 infer keyword is being used here, it’s not really an inference procedure at all, and it’s not an existential either. We instead capture the type of the member in a type variable.

Rules for MetaType2

This new form of meta types admits several kinds of constraints, all on object members.

An unconstrained member: infer X

In this case, the type of the member (which must exist) is captured in a type variable X.

A constrained member: infer X extends Y

In this case, the type of the member that’s captured must be a subtype of Y. Y can be any type expression, including one with circular references.

A type literal member: string

In this case, the type of the given member must match the specified type exactly.

Because this constraint can only be fulfilled in one way, we can use it to define implicit structure. Specifically, if we have this:

type type Silly = {
	explicit: infer X
	implicit: string
}

type type LessSilly = {
	explicit: infer X
	implicit: X[]
}

We don’t have to specify the member that doesn’t have infer, and it will instead be added automatically.

declare function silly<Types: Silly>(input: Types["implicit"])

silly<{
	explicit: number
//  implicit: string // ← added implicitly
}>("abc")

declare function lessSilly<Types: LessSilly>(input: Types["implicit"])

lessSilly<{
	explicit: string
//  implicit: string[] // ← added implicitly
}>(["a"])

Default types

We can add defaults using the following syntax:

type type Example = {
	something: infer X = string
}

X will be bound to the something property of an object, but if it lacks one something: string will be added via implicit structure. So this means that a type E: Example will have a something property whether one exists on the type supplied by the caller. So E["something"] will always be well-defined, even if it's defaulted.

@GregRos
Copy link
Author

GregRos commented Oct 5, 2023

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 X extends Record<keyof X, string>. SO question where this is needed

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.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Oct 23, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants