Type Alias RecordMapper<R, U, D>

RecordMapper: {
    [K in keyof R]: (value: R[K]) => unknown extends U[K] ? D : U[K]
}

represents an Object consisting of a collection of single-parameter functions that map the entries of type R to entries of type U. however, if U does not contain a certain key that's in R, then we will assume that it is being mapped to a single default type D.

to give you an idea, here is a flawed example: (more is covered on the flaw right after)

// here is a scenario where we want to remap game player stats from version `v1` to `v2`,
// in addition to wanting to count the number of player name duplications in `NamesTallyDB`.

// a record that keeps a tally (value) of the number of occurrences of each name (key)
const NamesTallyDB: Record<string, number> = {}

// some player's stats in version `v1`
const my_stats_v1 = {
name: "haxxor",
game: "league of fools and falafel",
fame: 505,
tame: false,
lame: ["yes", 735],
}

// a collection of functions that maps each entry of a player's stats in `v1` to `v2`.
const stats_v1_to_v2: RecordMapper<typeof my_stats_v1> = {
name: (s) => {
// `s` is automatically inferred as a `string`, thanks to `typeof my_stats_v1` generic parameter
NamesTallyDB[s] ??= 0
const repetitions = NamesTallyDB[s]++
return [s, repetitions]
},
game: (s) => s,
fame: (v) => v * 1.5,
tame: (b) => undefined,
lame: (a) => ({
current_status: a[0] === "yes" ? true : false,
bad_reputation_history: [["pre-v2", a[1]], ["original-sin", 5], ]
})
}

uh oh, did you notice the problem? the IDE thinks that stats_v1_to_v2 maps each entry of my_stats_v1 to unknown. you must provide a second type parameter that specifies the new type of each entry (which in this context would be StatsV2).

type StatsV2 = {
name: [string, number],
game: string,
fame: number,
tame: undefined,
lame: {
current_status: boolean,
bad_reputation_history: Array<[occasion: string, value: number]>
}
}

const stats_v1_to_v2: RecordMapper<typeof my_stats_v1, StatsV2> = {
// just as before
}

but this is a lot of repetition in typing, and the additional type will be utterly useless if it's not being used elsewhere. luckily, with the introduction of the satisfies operator in tsc 4.9, you can be far more concise:

// a record that keeps a tally (value) of the number of occurrences of each name (key)
const NamesTallyDB: Record<string, number> = {}

// some player's stats in version `v1`
const my_stats_v1 = {
name: "haxxor",
game: "league of fools and falafel",
fame: 505,
tame: false,
lame: ["yes", 735],
}

// the map function parameters `s`, `v`, `b`, and `a` all have their types automatically inferred thanks to the `satisfies` operator.
// `stats_v1_to_v2` now indeed maps the correct `stats_v2` interface, without us having to write out what that interface is.
const stats_v1_to_v2: RecordMapper<typeof my_stats_v1> = {
name: (s) => {
// `s` is automatically inferred as a `string`, thanks to `typeof my_stats_v1` generic parameter
NamesTallyDB[s] ??= 0
const repetitions = NamesTallyDB[s]++
return [s, repetitions]
},
game: (s) => s,
fame: (v) => v * 1.5,
tame: (b) => undefined,
lame: (a) => ({
current_status: a[0] === "yes" ? true : false,
bad_reputation_history: [["pre-v2", a[1]], ["original-sin", 5], ]
})
} satisfies RecordMapper<typeof my_stats_v1>

now, for an example that uses the optional generic type parameter D (3rd parameter) for declaring the default output type:

const now_i_know_my = { a: 1, b: 2, c: 3, s: "nein" }

const latin_to_greek: RecordMapper<
typeof now_i_know_my, // these are the inputs that will be mapped
{ s: number }, // the entry `"s"` will be mapped to a `number`
string // all other entries will be mapped to `string`
> = {
a: (v) => `${v}-alpha`,
b: (v) => `${v}-beta`,
c: (v) => `${v}-theta`,
s: (v) => 9,
}

latin_to_greek satisfies ({
a: (v: number) => string,
b: (v: number) => string,
c: (v: number) => string,
s: (v: string) => number,
})

Type Parameters

  • R
  • U extends { [K in keyof R]?: any } = { [K in keyof R]: unknown }
  • D extends any = unknown