I'm trying to find a clean way to combine type annotation and literal type inference (via as const
) in TypeScript, but I keep running into limitations where one seems to cancel out the other.
Use Case:
I'm working on an Angular app where I declare my routes normally - using the Routes
type from @angular/router
, like so:
const routes: Routes = [
{ path: 'home', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'contact', component: ContactComponent },
];
I want to define a separate metadata object that contains additional information for each route path. For type safety, I'd like to ensure the keys of this metadata object match exactly one of the path
values defined in routes
.
So I want something like this:
const metadata: RoutesMetadata<typeof routes> = {
home: { title: 'Home Page' },
about: { title: 'About Us' },
};
The Problem
In order to extract literal types from routes
, I need it to be declared as const
:
const routes = [
{ path: 'home', component: HomeComponent },
...
] as const;
But doing that removes compatibility with the Routes
type annotation (i.e. : Routes
), because I cannot declare it like this:
const routes: Routes = [ ... ] as const;
since as const
will have no effect.
So for now, I have traded away the IntelliSense from the type annotation an have something like this:
export type RoutePath<R extends Routes> = Extract<R[number]['path'], string>;
export type RouteMetaData<R extends Routes> = Record<RoutePath<R>, Metadata>
const routes = [
{ path: 'a' },
{ path: 'b'}
] as const satisfies Routes;
const routeMetadata: RouteMetaData<typeof routes> = {
'must throw error' <-- Object literal may only specify known properties, and ''must throw error'' does not exist in type 'RouteMetaData<[{ readonly path: "a"; }, { readonly path: "b"; }]>'.ts(2353) ✅✅✅
}
Literal type successfully inferred here but sadly I cannot define my routes normally as const routes: Routes = [...
and see the IntelliSense from Angular's type definition of Routes
.
The behavior you want can in fact be achieved with satisfies
. If you write the satisfies Routes
part first and then go back to fill in the array you ought to get IntelliSense. To see what I mean, try this in the TypeScript playground:
import { Routes } from '@angular/router'
const routes = [
// add elements here
] as const satisfies Routes
I suspect you already know the rest of this, but it may help future readers:
When you write this:
const routes: Routes = …
It means "infer a type for …
, then check if it is assignable to Routes
, then create a variable named routes
whose type is exactly Routes
". This is why you don't see the details about your specific routes; the Routes
type has path
typed as string
, so that's as much detail as you'll ever get when drilling into routes
later. In your question you phrased this as "as const
will have no effect", but to be more precise I'd say that it had an effect, but you told the type checker to forget about that effect when typing routes
.
On the other hand, when you write this:
const routes = … satisfies Routes
It means "infer a type for …
, then check if it is assignable to Routes
, then create a variable named routes
whose type is whatever was originally inferred for …
". Along with as const
, this means the type of routes
will contain the specific string literal types you used as path
s (while still checking that the value is a subtype of Routes
).