#Convert a deep object type with any string literal values into type `string`

60 messages · Page 1 of 1 (latest)

marsh turtle
#

How do I create a type helper that will deeply convert any value types that are string literal unions to mere string types? Here's my attempt:

type GenericStringType<T> =
  number | undefined | null | symbol | BigInt extends T
    ? T
    : string extends T
      ? T & string
      : { [P in keyof T]: GenericStringType<T[P]> }

I know this isn't right, and there's some better way to do the "early exit condition" so as to not have infinite recursion through non-object types, but I don't know what that is.

As an example:

const x = { a: 'value' } as const
const y = { a: 'anotherValue' } satisfies GenericStringType<typeof x>

So that y must have the exact same structure as x, both x and y are const types that have specific string literal values, but we can be sure that y and x are exactly compatible for properties and structure, assuming all string values are compared as strings and not as consts.

marsh turtle
#

Okay, this seems closer, but there's something I don't understand:

type GenericType<T> =
  T extends string
    ? string
    : T extends number
      ? number
      : T extends object
        ? { [P in keyof T]: GenericType<T[P]> }
        : T

I thought object just meant any non-null, non-undefined type. Wait, I'm getting that confused with type {}. Does object exclude all primitives?

weak stag
#

You can achieve this using conditional types in TypeScript. Here's an example of how you could write a type helper that deeply converts string literal unions to string types:

type Stringify<T> =
  T extends string
    ? T
    : T extends object
      ? { [P in keyof T]: Stringify<T[P]> }
      : string;

Here's how the type helper works:

If the input type T is already a string, return T.
If T is an object, map over its keys with a recursive call to Stringify for each value.
Otherwise, return string.

Here's how you could use it with your example:

const x = { a: 'value' } as const;
const y: Stringify<typeof x> = { a: 'anotherValue' }; // Ok
const z: Stringify<typeof x> = { a: 42 }; // Error: Type '42' is not assignable to type 'string'.

Note that the as const assertion is important to make sure that x is treated as having string literal types instead of just string.

marsh turtle
#

But I'll get a chance to respond at greater length later, not sure exactly when.

marsh turtle
#

Your response seems remarkably Chat-GPT-like, by the way...

wanton relic
wanton relic
#
type GenericStringType<T> =
    T extends string
    ? string
        : T extends object
        ? { [K in keyof T]: GenericStringType<T[K]> }
            : T;
#

I get:

type GenericStringType<T> =
    T extends string
    ? string
        : T extends object
        ? { [K in keyof T]: GenericStringType<T[K]> }
            : T;

type Input = {
    a: string;
    b: 'hello' | 'goodbye';
    c: {
        d: 'flamingo';
        e: {
            f: 'dante';
        };
        g: 3;
        h: number;
    };
    i: undefined;
    j: null;
};

type Result = GenericStringType<Input>

on hover we see

type Result = {
    a: string;
    b: string;
    c: {
        d: string;
        e: {
            f: string;
        };
        g: 3;
        h: number;
    };
    i: undefined;
    j: null;
}
marsh turtle
# wanton relic why the special case for number?

I forgot to include numbers so that const x: { a: 7 } as const, when used as the satisfies GenericType<typeof x> will treat 7 as type number, but I can deal with that myself from inference, I just ended up pasting in my later work without remembering to update the original question. 🙂

marsh turtle
wanton relic
#

if this is the case then I guess you could just do extends object

marsh turtle
#

No, primitives are things that don't have reference equality, such as string | number | BigInt | undefined | null (even though null technically returns 'object' from typeof, that's kind of wrong).

#

object in typescript is not the same as the runtime expression const typeOfX = typeof x where typeOfX gets the value 'object'.

#

Try const x: object = null in TypeScript and see if it likes it. ("TS52322: type 'null' is not assignable to type 'object'")

#

Reference equality means, you can change something in one variable, and it changes in another.

#
const x = { }
const y = x
x.prop = 'value'
// y now has prop: 'value" because y has "reference equality" to x

Thus, { } extending from Object is not a primitive, because it has reference equality.

Contrarily:

const x = 7
const y = x
// you can't do anything to x that will change y. x and y only have (temporary) value equality, not reference equality.
weak stag
viscid wind
#

you could probably just reverse Narrow tbh

#

!:narrow

rapid narwhalBOT
#
T6#2591

Preview:ts type _Narrow<T, U> = [U] extends [T] ? U : Extract<T, U> type Narrow<T = unknown> = | _Narrow<T, 0 | number & {}> | _Narrow<T, 0n | bigint & {}> | _Narrow<T, "" | string & {}> | _Narrow<T, boolean> | _Narrow<T, symbol> | _Narrow<T, []> | _Narrow<T, { [_: PropertyKey]: Narrow }> | (T extends object ? { [K in keyof T]: Narrow<T[K]> } : never) | Extract<{} | null | undefined, T> ...

viscid wind
#

!ts

rapid narwhalBOT
#
type _Widen<T, U> = [T] extends [U] ? U : Extract<T, U>
type Widen<T = unknown> =
| _Widen<T, number>
| _Widen<T, bigint>
| _Widen<T, string>
| _Widen<T, boolean>
| _Widen<T, symbol>
| (T extends object ? { [K in keyof T]: Widen<T[K]> } : never)
| Extract<T, null>
| Extract<T, undefined>;

type T1 = number[]
type T2 = { foo: string, bar: string }
type T3 = { foo: string[], bar: Record<string, number>, baz: boolean }

type What = Widen<{ a: 1; b: { c: ["a"] }; }>
//   ^? - type What = {
//       a: number;
//       b: {
//           c: [string];
//       };
//   }
let a!: What;```
viscid wind
#

_Widen could probably be type _Widen<T, U> = [T] extends [U] ? U : Extract<T, U>

marsh turtle
viscid wind
marsh turtle
viscid wind
#

type _Widen<T, U> = [T] extends [U] ? U : never

marsh turtle
#

I mean, I figured that part out 🙂

viscid wind
#

afaict the Extract is for narrowing the value

marsh turtle
#

Oh, sudden comprehension

viscid wind
#

via the T extends U inside the Extract. probably. idk inference that well XD

marsh turtle
#

And what does (0 | number & {}) extends ___ mean, where ___ is something number-like? I know & {} is the new way to exclude null and undefined, but why the 0 | number, and why the need to & {} anyway because number is not null or undefined?

#

Something about 0 being a subtype of a specific number?

viscid wind
#

that & {} prevents number from eating the 0

#

and the 0 is required to force typescript to narrow numbers - since to check "is this 0?" it has to know the specific value of the number (if there is one)

marsh turtle
#

That almost seems like exploiting undocumented features

marsh turtle
#

Isn't every number already a subtype of number?

#

type X = 0 extends number ? true : false this is true

#

By the way, thanks for the help. I'm getting useful stuff done, like being able to type a "local" config according to the same properties as a "production" config, while the final exported object is fully as const for both environments.

#

Combined with the PropertyPath helper, I even have some secrets-indirection that later I can turn into a proper external secret-handler.

#

This codebase needs so much work... secrets in the code chap my hide.

#

Convert a deep object type with any string literal values into type string

viscid wind
#

it sees 0 and checks whether it's 0 first

#

(probably)

viscid wind
viscid wind
#

hm?

marsh turtle
#

how does checking, say 6 against 0 help, or do anything?

viscid wind
#

because it might be 0

#

also the idea is that typescript has to narrow it to 6 first, to be able to compare it against 0

#

and that's the behavior Narrow is (ab)using

marsh turtle
#

Are you saying that this implicit narrowing ends up doing something for the comparison to number?