1

I'm trying to take an existing type as the basis for a new type, which has the following goals:

  • Contains original and renamed properties
  • Either original or renamed property is required if the original is a required property (something like this)
  • If original attribute is optional, renamed property is optional as well

I found a generic way to rename the properties already, but I have a hard time to figure out if there's a generic way to meet the other criteria.

Here's what I have:

export type Postfix<K extends string, T extends string> = `${T}${K}`;
export type Postfixer<K, T extends string> = {
  [P in keyof K as Postfix<T, string & P>]: K[P];
};

type Foo = {
  one: string;
  two: string;
  three: string;
}

type DuplicatedAndRenamed = Postfixer<Foo, ".$">;

// works already
const foo: DuplicatedAndRenamed = {
  "one.$": 'foo',
  "three.$": 'bar',
  "two.$": 'baz',
}

// goal which should work
const shouldWork: DuplicatedAndRenamed = {
  "one.$": 'foo',
  three: 'bar',
  "two.$": 'baz',
}

// goal which should not work
const shouldNotWork: DuplicatedAndRenamed = {
  "one.$": 'foo',
  three: 'bar',
  "three.$": 'bar',
  "two.$": 'baz',
}

Wondering if that's possible at all?

Ken White
  • 123,280
  • 14
  • 225
  • 444
Sebastian
  • 1,253
  • 1
  • 10
  • 11

1 Answers1

0

The following solution is a bit verbose. I am sure someone will find a shorter solution :)

export type Postfix<K extends string, T extends string> = `${T}${K}`;

type ExpandRecursively<T> = T extends object
  ? T extends infer O ? { [K in keyof O]: ExpandRecursively<O[K]> } : never
  : T;

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

type Postfixer<T, K extends string> = ExpandRecursively<
  UnionToIntersection<
    {
      [P in keyof T]-?: [
        | XOR<{ [Key in Postfix<K, string & P>]: T[P] }, { [Key in P]: T[P] }>
        | (undefined extends T[P]
            ? { [Key in Postfix<K, string & P> | P]: undefined }
            : never)
      ];
    }[keyof T]
  >
>[any];

type Foo = {
  one: string;
  two: string;
  three?: string;
}

type DuplicatedAndRenamed = Postfixer<Foo, ".$">;

The goal here is to create a union of all possible key combinations that an object of type DuplicatedAndRenamed may have. The first two elements of the union would look like this:

{
    "one.$"?: undefined;
    one: string;
    "two.$"?: undefined;
    two: string;
    "three.$"?: undefined;
    three?: string | undefined;
} | {
    "one.$"?: undefined;
    one: string;
    "two.$"?: undefined;
    two: string;
    three?: undefined;
    "three.$": string | undefined;
} | ... 9 more ... | {
    ...;
}

As you can see, each element of the union specifies exactly which properties must be defined and which must be undefined for each possible case.

This implementation uses two handy utility types. XOR from this answer will allow us to enforce that either the key with the postfix is allowed or the one without, but not both.

We also need UnionToIntersection from this answer to convert unions to intersections (as the name already implies).

For the implementation of Postfixer we map over the keys of T. For each key we create a tuple containing the XOR'd type of the key and postfixed key. If the key of T is optional we also add another element to the union where both keys are undefined.

| (undefined extends T[P]
  ? { [Key in Postfix<K, string & P> | P]: undefined }
  : never)

As you can see, the computed XOR type is wrapped inside a tuple. This will keep the UnionToIntersection from intersecting the XOR'd type. After we intersected the unions of all the mapped keys, we only need to access the resulting indexed type with [any].

Let's see if this passes all tests:

// works
const shouldWork1: DuplicatedAndRenamed = {
  "one.$": 'foo',
  "three.$": 'bar',
  "two.$": 'baz',
}

// works: mix of postfix and non-postfix
const shouldWork2: DuplicatedAndRenamed = {
  "one.$": 'foo',
  three: 'bar',
  "two.$": 'baz',
}

// works: three is not here because optional
const shouldWork3: DuplicatedAndRenamed = {
  "one.$": 'foo',
  "two.$": 'baz',
}

// Error: three and three.$ are not allowed together
const shouldNotWork: DuplicatedAndRenamed = {
  "one.$": 'foo',
  three: 'bar',
  "three.$": 'bar',
  "two.$": 'baz',
}

Playground

Tobias S.
  • 21,159
  • 4
  • 27
  • 45
  • I am still not sure what causes the typescript playground links to be cut off sometimes :( – Tobias S. May 25 '22 at 22:33
  • A mildly [shorter](https://tsplay.dev/NVZqGN) version, using a tuple of length 1 and getting rid of the `key` property by indexing into the result with `any`. It's probably also a good idea to include that you need to wrap the computed type so it isn't naked and gets lost when you use `UnionToIntersection`. – kelsny May 25 '22 at 22:43
  • That is indeed more concise :) – Tobias S. May 25 '22 at 22:52