This question is all about a good TypeScript solution for managing all your paths of your filesystem structure.
When I came across web-based local applications or website backend pages, to manage the filesystem structure's paths, I would create a
// the program's data dir on local machine
const appDataDir = "/path/to/program/data"
export const paths = {
data: {
settings: "settings.json",
translations: "translations/",
translationList: "translations.msgpack",
},
}
initPaths(paths)
// concat "settings.json" etc. with a base dir path
function initPaths(p: typeof paths) {
for (const key in p.data) {
p.data[key] = resolve(appDataDir, p.data[key])
}
}
The above is simple, just do paths.data.settings
and you get the path. Later on, I discovered that most of the files and directories are nested in a structured form (that's what folders do, right?), so I developed it to join paths in nested objects.
paths -> content -> pages
)This is
// example paths that I'm trying to get
// "./"
// "./content"
// "./content/pages/Hello World!/attachments"
// "./auth.json"
const self = ""
export const paths = {
self,
content: {
self,
pages: {
self,
},
},
auth: "auth.json",
}
// concat the paths of the nested objects recursively...
function resolvePath(
paths: Record<string, string | object>,
base: string = "/path/to/root/dir",
) {
for (const key in paths) {
const t = typeof paths[key]
if (t === "object") {
let dirPath: string
if (key === "any") {
dirPath = "."
} else {
dirPath = join(base, key)
if (!existsSync(dirPath)) {
mkdirSync(dirPath)
}
}
resolvePath(paths[key] as Record<string, string | object>, dirPath)
} else if (t === "string") {
paths[key] = join(base, paths[key] as string)
}
}
}
resolvePath(paths)
Consider that I wanted to represent a wildcard or some pattern to constrain some path pieces. How about adding a special name "any"
, and treat it specially in the resolvePaths
?
// I want paths like "./content/pages/Category#1/Article#2/files"
// ~~~~~~~~~~~~~~~~~~~~ = "any"
const self = ""
export const paths = {
self,
content: {
self,
pages: {
self,
any: {
self,
attachments: { self },
files: { self },
},
},
},
auth: "auth.json",
}
Man, that's literally "any", maybe I should implement a concat function which don't accept path pieces that don't exist.
const concatHelper: <T extends PathPieces[]>(...path: T)
concatHelper("content") // Good
concatHelper("hello") // Error!
concatHelper("auth.json") // Good
concatHelper("content", "hi") // Error!
concatHelper("content", "pages") // Good
concatHelper("content", "pages", "Article#1") // Good
That's the problem, representing that "any" is difficult (just look at the A glimpse of what I've tried and you'll know). A static path structure is simple, but it becomes complicated when matching patterns. The complexity shows up especially when writing a concat function that constrains path pieces with types (oh string literal types).
So I've tried the version 1, just static paths, and version 2 the nested objects. It's not a function not working, or an algorithm is having bugs. It's about finding a solution to manage paths of a filesystem structure, specifically in this concat function solution, to construct a TypeScript type (That's hard, I tried in Option 2).
type Dir<Name extends string, Content extends string = ""> = `${Name}/${"" | Content}`
// prettier-ignore
export type Paths = Dir<".",
| Dir<"content",
| Dir<"pages",
| Dir<string,
| "index.md"
| Dir<"attachments">
| Dir<"files">
>
>
>
| "auth.json"
>
Do not try to understand if you can't, just take all these Meta Programming for fun
import { join } from "node:path"
// "ts-arithmetic" package is really cool, arithmetic with number types!
import type { Add, Lt, Subtract } from "ts-arithmetic"
type File = string
interface Dir {
name: string
children: Item[]
}
type Item = Dir | File
function dir<T extends string, U extends Item[]>(name: T, ...children: U) {
return {
name,
children,
} as const
}
const wildCard: string = "*"
const pathss = dir(
"",
dir("b", dir("haha", dir("wildCard", dir("attachments", dir("files")), dir("files")))),
dir("content", dir("pages", dir(wildCard, dir("attachments"), dir("files")))),
dir("a", dir("pagesaa", dir(wildCard, dir("attachments"), dir("files")))),
)
type Paths = typeof pathss
type Children<T extends Dir> = T["children"][number]
type GetName<T extends Item> = T extends Dir ? T["name"] : T
type GetDir<T extends Item> = T extends Dir ? T : never
type SubPaths<T extends Dir> = GetName<Children<T>>
type SubDirs<T extends Dir> = GetDir<Children<T>>
type Indices<T extends unknown[]> = {
[K in keyof T]: K
}[number]
type IndexOf<T extends string, U extends Item[]> = {
[K in Indices<U>]: GetName<U[K]> extends T ? K : never
}[Indices<U>]
type Range<A extends number, B extends number> =
Lt<A, B> extends 1 ? A | Range<Add<A, 1>, B> : never
type a = Indices<Paths["children"]>
type t = Range<1, 10>
type s = IndexOf<"content", Paths["children"]>
type Slices<T extends Dir, Length extends number> = Length extends 0
? []
: [SubDirs<T>["name"], ...Slices<SubDirs<T>, Subtract<Length, 1>>]
// I gotta try another way...
const test: Slices<Paths, 3> = ["b", "pages", "asdfasdfasdfasdf"]
type As<T, U> = T extends U ? T : never
type Slices2<T extends Dir, Length extends number> = {
length: Length
} & {
[K in Range<0, Length>]: K extends 0
? SubDirs<T>["name"]
: Slices2<T, Length>[As<Subtract<K, 1>, Range<0, Length>>]
}
// Why is this not erroring?!
const test2: Slices<Paths, 3> = ["content", "haha", "b"]
function joinHelper<
U extends [...string[]] & Slices2<Base, Length>,
Length extends number = U["length"],
Base extends Dir = Paths,
>(base: Base, [slice, ...slices]: [...U]): string {
return join(slice, joinHelper(base, slices))
}
The approach I'd take here is to write a recursive conditional type ConcatHelperArgs<T>
that takes a type T
corresponding to paths
and produces a union of tuples corresponding to the allowed argument lists for concatHelper()
. Meaning we'd write
declare const concatHelper: (...args: ConcatHelperArgs<typeof paths>) => void;
where args
is a rest parameter with a tuple type. Given paths
defined like:
const self = ""
export const paths = {
self, content: {
self, pages: {
self, any: { self, attachments: { self }, files: { self } },
},
},
auth: "auth.json",
} as const
(and note how I used an as const
const
assertion so that TS would keep track of the literal type of "auth.json"
instead of interpreting it as string
), then we want ConcatHelperArgs<typeof paths>
to evaluate to:
type X = ConcatHelperArgs<typeof paths>;
/* type X = [] | ["content"] | ["content", "pages"] | ["content", "pages", string] |
["content", "pages", string, "attachments"] | ["content", "pages", string, "files"] |
["auth.json"] */
You can verify that if you hardcode that union into the definition of concatHelper
that it behaves as expected for the paths
example in the question. Note that your IDE will probably helpfully auto-suggest string literals like "content"
and "pages"
, but for the string
values, it might end up unhelpfully auto-suggesting these, and there's not much to be done about that. The types are right, even if your IDE experience isn't the best.
So how do we define ConcatHelperArgs
? Here's on possibility:
type ConcatHelperArgs<T> = T extends object ? {
[K in keyof T]: K extends "self" ? [] : [
K extends "any" ? string :
T[K] extends string ? T[K] : K,
...ConcatHelperArgs<T[K]>]
}[keyof T] : []
It's recursive... the base case is that the type T
you pass in is not an object, in which case you don't want to accept any arguments at all: that's the empty tuple []
. If T
is an object, then you want to process each key K
in keyof T
separately, and then combine the results in a union. That is, you want to distribute the processing across unions in keyof T
. So we write a distributive object type (as coined in microsoft/TypeScript#47109) of the form {[K in keyof T]: ⋯K⋯}[keyof T]
, and ⋯K⋯
needs to be replaced with the union of tuples for each key K
.
What we have for ⋯K⋯
is the chunk that starts K extends "self" ? [] : [⋯]
. If the key is named "self"
then we don't want to accept any more arguments, so it's the empty tuple. If the key is not named "self"
then we have the chunk that looks like [K extends "any" ? string : ⋯]
. So if the key is named "any"
then we want the first argument to be any string
at all. Otherwise we have [⋯ T[K] extends string ? T[K] : K, ⋯]
, so if the value of the property at key K
is a string (like "auth.json"
) then we want to accept that value and not the key... otherwise we want to accept the key.
Finally we have the recursive case, which is to spread ConcatHelperArgs<T[K]>
into the rest of the tuple. So for each key K
with determine the first acceptable argument (either string
or K
or T[K]
, depending), and then we find the rest of the acceptable arguments by recursing down into T[K]
.
You can verify that ConcatHelperArgs<typeof paths>
evaluates to the union shown above.
That's the basic approach. Of course I had to make assumptions about what to do in edge cases and what was meant by "any"
and "self"
and what to do when you have a string value (note that ""
would mean an empty string, which isn't the same as ending the argument list. So unless you want to accept concatHelper("content", "")
, then we have to identify "self"
instead of ""
. And of course these assumptions might not align completely with the intended use cases outside of the example given here. If so, you might have to tweak the definition of ConcatHelperArgs
accordingly. But that's outside the scope of the question as asked.