Sebastian Nemeth
Sebastian Nemeth

Reputation: 6175

In typescript, in a typed function argument, optional properties are ignored by intellisense. Why?

I'm trying to create a router initialiser with a nice typed DX in typescript.

My goal is to allow the developer to define a path and params types for each route's params at each level. My example is truncated, in order to better demonstrate my problem.

Each route node looks like:

export type RouteArgs<TProps extends t.Props> =
{
  path: string;
  type?: TProps;
};

In order to preserve typing, but enforce type constraints, I create a 'builder' function:

export type RoutesArgs = {
  [key: string]: RouteArgs<{}>;
};

interface RoutesFn {
  <TRoutesArgs extends RoutesArgs>(args: TRoutesArgs): TRoutesArgs;
}

export const routes: RoutesFn = args => args;

The routes function's only purpose is to enforce typing of the arguments, and return the actual type inferred from the arguments. It is used like this:

const r = routes({
  FOO: {
    // Intellisense kicks in here...
  },
});

When intellisense kicks in for the FOO object, it suggests both path and type properties. path is required, so I add path, and hit intellisense again.

const r = routes({
  FOO: {
    path: '/foo',
    // Now, intellisense is empty...
  },
});

Instead of recommending just type, as expected, it now doesn't suggest anything.

In fact, it also allows me to add 'garbage' properties...

const r = routes({
  FOO: {
    path: '/foo',
    // Ideally, it would complain about the following...
    rubbish: 1234
  },
});

I fear I am running up against a feature of typescript's structural typing. I think it sees path as satisfied and then gives up matching the rest.

Part of the charm of this approach is that it's just an object literal and I don't have to instantiate classes for each route node, so I'd like to avoid having to use classes.

Is there any way to convince typescript to enforce nominal typing in this kind of scenario without resorting to a pass-through function for each route?

Upvotes: 0

Views: 145

Answers (1)

jcalz
jcalz

Reputation: 328598

I can't reproduce the problem you're having with IntelliSense.

As for forbidding extra properties, you're right that the structural type system allows for such extra properties by default. (Specifically it's required by interface and class extension... if adding properties to a subclass or an interface extension resulted in incompatibility with the superclass or parent interface, inheritance and subtyping would be unwieldy.) Object types in TypeScript are generally not treated as "exact". I say "generally" because the language has carved out an exception for object literals in certain situations with excess property checking. In your case this does not apply, though, because the object literal is of a generic type that extends an object type, and since extensions may have extra properties, you're back where you started.

So TypeScript doesn't support exact types directly. But you can indirectly describe such types via generic type constraints. For example:

type Exactly<T, C> = T & { [K in Exclude<keyof C, keyof T>]: never };

interface RoutesFn {
  <T extends RoutesArgs>(args: T & { [K in keyof T]: Exactly<RouteArgs<{}>, T[K]> }): T;
}

The Exactly<T, C> generic type takes a type T and a candidate type C, and returns a new type which is compatible with C if and only if it an "exact" match for T. If C contains any extra properties, these extra properties get transformed into type never.

Then the RoutesFn type is similar to before, except that the args parameter is checked against both your constrained generic T (I've shortened it from TRoutesArgs) and a version where each property of T has been checked against an exact version of RouteArgs<{}>.

Let's see it work:

const r = routes({
  FOO: {
    path: '/foo',
    type: { f: 1 },
    rubbish: 1 // error!
    //~~~~~ <-- Type 'number' is not assignable to type 'never'
  },
  BAR: {
    path: '/bar',
  }
});

So that's good; you now get an error on extra properties without losing the type inference. There's also still IntelliSense, at least in my IDE (see image).


Okay, hope that helps; good luck!

Playground link to code

Upvotes: 1

Related Questions