Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I mask a generic type for intellisense?

Tags:

typescript

Context

I am creating a data validation and query library of sorts. A user can define a schema and make queries, where the results are typed. A minimal example looks something like this:

const g = graph({
  posts: object({
    title: string()
  }), 
  authors: object({
    name: string().optional()
  })
})

const post = query(g, 'posts')
post.title // This is typed as a string!

Problem

However, I am plagued by really complex-looking types. For example, if you hover over g in VSCode, you will see:

const g: Graph<{
    posts: ObjectDef<{
        title: DataAttrDef<string, true>;
    }>;
    authors: ObjectDef<{
        name: DataAttrDef<string, false>;
    }>;
}>

Similarly, if you hover over query:

function query<Graph<{
    posts: ObjectDef<{
        title: DataAttrDef<string, true>;
    }>;
    authors: ObjectDef<{
        name: DataAttrDef<string, false>;
    }>;
}>, "posts">(graph: Graph<...>, _objectType: "posts"): {
    ...;
}

I would love it if hovering over g, showed something a type like MyGraph:

// Desired behavior: 

type MyGraph = typeof g; 

g // Hover over g -> MyGraph

// Hover over query: 
function query<MyGraph, "posts">(graph: MyGraph, _objectType: "posts"): {
    ...;
}

Question

Is there any way to make the intellisense output for g and query look nicer?

I made a smaller, reproducible version of this library and put in on the TS playground. It looks like this:

class DataAttrDef<V, R extends boolean> {
  constructor(public valueType: V, public required: R) { }
  optional(): DataAttrDef<V, false> {
    return new DataAttrDef(this.valueType, false);
  }
}

function string<S extends string = string>(): DataAttrDef<S, true> {
  return new DataAttrDef("string" as S, true);
}

type ExtractValueType<T> = T extends DataAttrDef<infer V, infer R>
  ? R extends true ? V : V | undefined : never;

interface DataAttrsDefs { [key: string]: DataAttrDef<any, any>; }

class ObjectDef<A extends DataAttrsDefs> { constructor(public attrs: A) { } }
function object<A extends DataAttrsDefs>(attrs: A): ObjectDef<A> { return new ObjectDef(attrs); }

type ExtractObjectType<T> =
  T extends ObjectDef<infer A> ? { [K in keyof A]: ExtractValueType<A[K]> } : never;

interface ObjectDefs { [key: string]: ObjectDef<any>; }

class Graph<O extends ObjectDefs,> { constructor(public objects: O) { } }
function graph<O extends ObjectDefs>(defs: O): Graph<O> { return new Graph(defs); }

type QueryResponse<G extends Graph<ObjectDefs>, K extends keyof G["objects"]> =
  ExtractObjectType<G["objects"][K]>;

function query<G extends Graph<ObjectDefs>, K extends keyof G["objects"]>(
  graph: G, _objectType: K): QueryResponse<G, K> { return {} as any; }

// --- 1) hover over g. type looks really large
const g = graph({
  posts: object({ title: string() }),
  authors: object({ name: string().optional() })
})
             // --- 2) hover over `query`. this looks really hard to read
const post = query(g, 'posts')

post.title // This is typed as a string!

I know that Typescript has a known issue about improving type readability. My hope is that perhaps there's some way I can rewrite this validation library, to make the current types more readable.

like image 241
Stepan Parunashvili Avatar asked Oct 23 '25 16:10

Stepan Parunashvili


2 Answers

For that to work, you'd need to reverse the logic: instead of keeping ObjectDefs in your type parameters, you should keep plain objects with simple values there.

As an example (this might not work fully for your use-case but should give you an idea):

// define allowed shape of values
interface Shape {
  [key: string]: string | Shape
}

// generate type of ObjectDefs based on values, not the other way round
type ObjectDefsOfShape<TS extends Shape> = {
  [K in keyof TS]: DataAttrDef<TS[K], true>
};

// Make the generic type expect simple values instead of ObjectDefs
class Graph<TS extends Shape> {
  constructor(public objects: ObjectDefsOfShape<TS>) { }
}

function graph<TS extends Shape>(defs: ObjectDefsOfShape<TS>) {
  return new Graph(defs);
}

const g = graph({
  posts: string(),
  authors: string()
});

and now, when hovering over g, you get:

const g: Graph<{
    posts: string;
    authors: string;
}>
like image 150
Michał Miszczyszyn Avatar answered Oct 26 '25 07:10

Michał Miszczyszyn


As you saw, using type aliases doesn't always result in your type alias being displayed. TypeScript will often expand a type alias into its definition, which doesn't help you. On the other hand, interfaces (and also class instance types), are usually displayed directly. So if you can get the type of g to be an interface named MyGraph then it should serve your needs:

// some appropriate definition
interface MyGraph { ⋯ }

const g: MyGraph = graph({
  posts: object({ title: string() }),
  authors: object({ name: string().optional() })
})
/* const g: MyGraph */

const post = query(g, 'posts')
/* function query<MyGraph, "posts">(graph: MyGraph, _objectType: "posts"): {
    title: string;
} */

So, we need an appropriate definition of MyGraph. Note that not all types can be written as interfaces (e.g., union types cannot be written as interfaces). But since g is an instance of a class declaration, it will work. (Indeed, the instance type of a class effectively forms an interface of the same name.)

You could write it out yourself manually, but presumably you want to derive it from typeof g. Well, oops, that won't work above since g is annotated as MyGraph. So what we really want to do is derive it from the return type of graph(). So we could do that in two steps, by first assigning the return value of graph to an intermediate variable:

const _g = graph({
  posts: object({ title: string() }),
  authors: object({ name: string().optional() })
})

And then using that variable to define MyGraph before assigning that value to g:

// some way of deriving MyGraph from typeof _g
interface MyGraph ⋯ typeof _g ⋯ 

const g: MyGraph = _g;

The easiest way to derive an interface type from another type is via interface extension using extends. Conceptually you'd write

// error, can't do this
interface MyGraph extends typeof _g {}

but unfortunately that's a syntax error because of a syntactic rule that the extended type needs to be a named type. Luckily you can easily work around that by giving a named type to typeof _g and then extending that. Like this:

type _MyGraph = typeof _g;
interface MyGraph extends _MyGraph { }

Or if you want something a little more reusable, you can just give a generic type name like

type Id<T> = T;
interface MyGraph extends Id<typeof _g> { }

Either way works.


Let's put it all together and verify that it behaves as desired:

const _g = graph({
  posts: object({ title: string() }),
  authors: object({ name: string().optional() })
})
        
type Id<T> = T;
interface MyGraph extends Id<typeof _g> { }
const g: MyGraph = _g;
/* const g: MyGraph */

const post = query(g, 'posts')
/* function query<MyGraph, "posts">(graph: MyGraph, _objectType: "posts"): {
    title: string;
} */

Looks good. The g variable is of type MyGraph, and the information shown on query() refers only to that type.

Playground link to code

like image 44
jcalz Avatar answered Oct 26 '25 06:10

jcalz



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!