TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Need way to express hybrid types that are indexable for a subset of properties

Open aaronjensen opened this issue 8 years ago • 39 comments

Edit by @DanielRosenwasser: This might be thought of as a "rest index signature" or a catch-all index signature.


This is a feature request.

TypeScript Version: 2.4

Code

interface CSSProperties {
  marginLeft?: string | number
  [key: string]: CSSProperties
}

Based on the docs, this is not allowed:

While string index signatures are a powerful way to describe the “dictionary” pattern, they also enforce that all properties match their return type. This is because a string index declares that obj.property is also available as obj[“property”]. In the following example, name’s type does not match the string index’s type, and the type-checker gives an error:

Unfortunately, it seems to make this type (which is common amongst jss-in-css solutions) not expressible. Coming from flow, which handles index types by assuming they refer to the properties that are not explicitly typed, this is frustrating.

As it stands, you can workaround it with:

interface CSSProperties {
  marginLeft?: string | number
  [key: string]: CSSProperties | string | number
}

But this is not sound. It allows this:

const style: CSSProperties = {
  margarineLeft: 3
}

This could potentially be solved with subtraction types if they allowed subtracting from string (and you were allowed to specify key types in this way):

interface CSSProperties {
  marginLeft?: string | number
}

interface NestedCSSProperties extends CSSProperties {
  [key: string - keyof CSSProperties]: CSSProperties
}

I asked about this on stackoverflow to confirm that I wasn't missing something. It seems I'm not, so I guess I'd consider this a suggestion/discussion starter, since it's probably not a "bug". Thanks!

aaronjensen avatar Aug 17 '17 18:08 aaronjensen

It seems like you can actually do this by using intersection types.

interface I {
    [key: string]: string;
}

interface C {
    foo: boolean;
    bar: number;
}

type T = I & C;
declare let t: T;
t.foo; // boolean
t.bar; // number
t.baz; // string

http://www.typescriptlang.org/play/#src=interface%20I%20%7B%0D%0A%20%20%20%20%5Bkey%3A%20string%5D%3A%20string%3B%0D%0A%7D%0D%0A%0D%0Ainterface%20C%20%7B%0D%0A%20%20%20%20foo%3A%20boolean%3B%0D%0A%20%20%20%20bar%3A%20number%3B%0D%0A%7D%0D%0A%0D%0Atype%20T%20%3D%20I%20%26%20C%3B%0D%0Adeclare%20let%20t%3A%20T%3B%0D%0At.foo%3B%0D%0At.bar%3B%0D%0At.baz%3B

ajafff avatar Aug 17 '17 18:08 ajafff

@ajafff No, that doesn't work if you actually want to assign to a variable of that type.

interface I {
    [key: string]: string;
}

interface C {
    foo: boolean;
    bar: number;
}

type T = I & C;
let t: T = {
    asdf: 'foo',
    foo: true,
    bar: 3,
}; 

http://www.typescriptlang.org/play/#src=interface%20I%20%7B%0D%0A%20%20%20%20%5Bkey%3A%20string%5D%3A%20string%3B%0D%0A%7D%0D%0A%0D%0Ainterface%20C%20%7B%0D%0A%20%20%20%20foo%3A%20boolean%3B%0D%0A%20%20%20%20bar%3A%20number%3B%0D%0A%7D%0D%0A%0D%0Atype%20T%20%3D%20I%20%26%20C%3B%0D%0Adeclare%20let%20t%3A%20T%3B%0D%0At.foo%3B%0D%0At.bar%3B%0D%0At.baz%3B#src=interface%20I%20%7B%0D%0A%20%20%20%20%5Bkey%3A%20string%5D%3A%20string%3B%0D%0A%7D%0D%0A%0D%0Ainterface%20C%20%7B%0D%0A%20%20%20%20foo%3A%20boolean%3B%0D%0A%20%20%20%20bar%3A%20number%3B%0D%0A%7D%0D%0A%0D%0Atype%20T%20%3D%20I%20%26%20C%3B%0D%0Alet%20t%3A%20T%20%3D%20%7B%0D%0A%20%20%20%20asdf%3A%20'foo'%2C%0D%0A%20%20%20%20foo%3A%20true%2C%0D%0A%20%20%20%20bar%3A%203%2C%0D%0A%7D%3B%20%0D%0A

aaronjensen avatar Aug 17 '17 19:08 aaronjensen

I will admit that I still don't fully understand the intent here. Flow advocates are always trumpeting soundness and then this happens

var x = 'hello' + 'world';
type Thing = {
  [index: string]: number;
  helloworld?: string;
};

var j: Thing = {
  [x]: 32
};
if (j.helloworld) {
  // crash, no error
  j.helloworld.substr(3);
}

Is the justification that objects initializing these types should never be constructed with dynamic keys? Is that a requirement that should be enforced? What should j[some_arbitrary_string]'s type be?

RyanCavanaugh avatar Aug 17 '17 19:08 RyanCavanaugh

@RyanCavanaugh Sorry, I'm not trying to make this a flow vs typescript soundness debate. I'm trying to type a very specific thing referenced in the OP:

interface CSSProperties {
  marginLeft?: string | number
  [key: string]: CSSProperties
}

Arbitrary keys are allowed, but they must be of type CSSProperties. This is not possible in Typescript afaict. This is not about soundness, it's about a type being not expressible in Typescript.

Soundness comes in if I attempt to work around Typescript's limitation:

interface CSSProperties {
  marginLeft?: string | number
  [key: string]: CSSProperties | string | number
}

In this case, I can do this:

const style: CSSProperties = {
  margarineLeft: 3
}

Which is wrong, but typechecks. In other words, I can typo the property names, which is one of the things I'm trying to protect against with types. For completeness sake, this would not be an error:

const style: CSSProperties = {
  margarineLeft: {
    marginLeft: 3
  }
}

aaronjensen avatar Aug 17 '17 19:08 aaronjensen

Sorry for the tangent. I just mean, the index declaration means "If you index by a string, here's what you get", so it's contradictory to allow you to write property declarations for which that statement isn't true.

We can have some other syntax that means "If a property exists and isn't one of the following known properties, then it must have this type", but then we have to decide what it means when you're indexing (for read or write) by a computed property name which we can't immediately resolve to determine if it should have the index type or a declared property's type.

What would you want to happen here?

var s = "qqqmarginLeft".substr(Math.random() * 7);
style[s] = 32;

RyanCavanaugh avatar Aug 17 '17 20:08 RyanCavanaugh

Ah, yes, I understand the concern. AFAICT flow does what you describe as "If a property exists and isn't one of the following known properties, then it must have this type", but likely falters in the case that you describe.

I'd be fine with a separate syntax.

In your example, I would want that to be a type error. In that case, style[s] should be treated as type (string | number) & CSSProperties which is impossible.

I would expect something like this to work, however:

interface CSSProperties {
  marginLeft?: string | number
  marginRight?: string | number
  [key: string]: CSSProperties
}

var s: 'marginLeft'|'marginRight' = left ? 'marginLeft' : 'marginRight'
style[s] = 32

aaronjensen avatar Aug 17 '17 20:08 aaronjensen

Oh, and I'd only expect style[s] to be treated that way on writes, it should be a union on reads.

aaronjensen avatar Aug 17 '17 20:08 aaronjensen

Perhaps we could use subtraction types to express this. { [P in string - "marginLeft"]: CSSProperties }?

masaeedu avatar Nov 13 '17 02:11 masaeedu

It's a shame this does appear to be being discussed further. It's something that I come up against quite often when wanting to make interface shape type safety tighter. If not for the recursive case, but for the case as in the issue title.

WoodyWoodsta avatar Feb 18 '19 11:02 WoodyWoodsta

Just came across the exact same situation, any updates on the issue?

kesemdavid avatar Mar 01 '19 10:03 kesemdavid

I came across this limitation when trying to create a type definition for an object like this:

import { PropertiesFallback } from 'csstype';

interface CSSPropertiesAndMediaQueryDelimitedCSSProperties<TLength = string | 0> extends PropertiesFallback<TLength> {
  [K: string]: PropertiesFallback<TLength>
}

const styles: CSSPropertiesAndMediaQueryDelimitedCSSProperties<string | number> = {
  fontSize: 32,
  lineHeight: 48,
  '@media (min-width: 1280px)': {
    fontSize: 48,
    lineHeight: 72,
  },
};

PropertiesFallback<TLength> describes “an object of known CSS camelCase properties (according to csstype) and valid values for that CSS property. Therefore, I’m trying to define an object type whose properties are either:

  • A known CSS camelCase properties and valid values therein (PropertiesFallback<TLength>), or
  • Some string value — disjoint from keyof PropertiesFallback<TLength> — whose value is of type PropertiesFallback<TLength> itself

This matters to me because I don’t want the nested object to contain unknown properties:

const invalidNestedStyles: CSSPropertiesAndMediaQueryDelimitedCSSProperties<string | number> = {
  fontSize: 32,
  lineHeight: 48,
  '@media (min-width: 540px)': {
    fontSize: 36,
    lineHeight: 54,
    // This should error, because the nested objects should
    // always conform to `PropertiesFallback<TLength>`
    '@media (min-width: 1280px)': {
      fontSize: 48,
      lineHeight: 72,
    },
  },
};

(Note that I would also prefer to verify that the “media query” keys actually begin with the string '@media', as in https://github.com/microsoft/TypeScript/issues/12754, but that’s well beyond the scope of this issue.)

Maybe this would work if we could use a conditional type to describe the value of an index signature:

// Note: doesn’t actually work with Typescript 3.4.5
interface CSSPropertiesAndMediaQueryDelimitedCSSProperties<string | number> {
  [K: string]: K extends keyof PropertiesFallback ? PropertiesFallback[K] : PropertiesFallback;
}

I got very close to this using a generic mapped type with conditional types (source):

import { PropertiesFallback } from 'csstype';

export type InferredCSSAndMediaQueryDelimitedCSSProperties<
  T extends object,
  TLength = string | 0,
  V = PropertiesFallback<TLength>
> = { [K in keyof T]: K extends keyof V ? V[K] : V };

However, when I used a generic mapped type like this, along with a generic function, I ran into a separate limitation on type-checking spread objects containing duplicate computed keys (maybe https://github.com/microsoft/TypeScript/issues/25758). This gave me enough doubt in the robustness of this workaround that I sought out this issue.

Thanks to these two comments (https://github.com/microsoft/TypeScript/issues/17867#issuecomment-323162868 and https://github.com/microsoft/TypeScript/issues/17867#issuecomment-323164375), I also tried an intersection type (source):

import { PropertiesFallback } from 'csstype';

export interface MediaQueryDelimitedCSSProperties<TLength = string | 0> {
  [K: string]: PropertiesFallback<TLength>;
}

export type CSSAndMediaQueryDelimitedCSSProperties<
  TLength = string | 0
> = PropertiesFallback <TLength> &
  MediaQueryDelimitedCSSProperties<TLength>;

However, as @aaronjensen pointed out in his follow-up comment (https://github.com/microsoft/TypeScript/issues/17867#issuecomment-323164375), this doesn’t correctly support assignment (or in my case, calling a function with an argument of this type).

Anyway, I hope that sheds some light on possible use cases.

kohlmannj avatar May 27 '19 21:05 kohlmannj

I have the same need.

It seems assignment is not possible, but you can assign line by line.

For example:

interface I {
    [key: string]: string;
}

interface C {
    foo?: boolean;
    bar?: number;
}

type T = I & C;

// this does not work:
let t: T = {
    asdf: 'foo',
    foo: true,
    bar: 3,
}; 

// this does work:
let t: T = {};
t.asdf = 'foo';
t.foo = true;
t.bar = 3;

promontis avatar Jul 09 '19 13:07 promontis

Just cross-linking: #26797 and #29317 together would have enabled this:

type DefaultProperty<T extends object, V> = T & {[k: (keyof any) & not (keyof T)]: V};

type CSSProperties = { marginLeft?: string | number } &
    { [k: (keyof any) & not "marginLeft"]: CSSProperties };

jcalz avatar Oct 30 '19 14:10 jcalz

@promontis Whoa this is weird: Re: https://github.com/microsoft/TypeScript/issues/17867#issuecomment-509651682

So, type intersection allows you to do this magic thing when assigning individual properties, but not when assigning the entire object as an object literal?

It seems like TS isn't consistent in how it's thinking about the type. I'm wondering which way is "correct" and which is a bug, or happenstance, and if this technique is safe to use with forward compatibility.

Just to re-iterate, this is the behavior:

type Magic = { [key: string]: object } & { specialKey?: true };

let testObj: Magic = {};
testObj.specialKey = true; // this works
testObj.anyStringKey = {}; // this works

testObj.anyStringKey2 = true; // desired error: Type 'true' is not assignable to type 'object'.
testObj.specialKey = {}; // desired error: Type '{}' is not assignable to type 'true'

const test = testObj.specialKey; // type: true | undefined
const test2 = testObj.something; // type: object

// Error with object literal assignment:
testObj = { // Type '{ specialKey: true; }' is not assignable to type 'Magic'.
  specialKey: true, // Type 'true' is not assignable to type 'object'
};

@RyanCavanaugh Sorry to bug you on this one – but is it safe to use this individual assignment method? Is this intended? Or is this subject to change in the future?

StephenHaney avatar Jan 22 '20 03:01 StephenHaney

Also, you get an error message on the interface when you try to extend this type:

type Magic = { [key: string]: object } & { specialKey?: true };
//        ˯ error: TS2411: Property 'specialKey' of type 'true | undefined' is not assignable to string index type 'object'.
interface MoreMagic extends Magic {} 

I'm not sure but to me it feels incorrect to get that message on the interface and not on the type intersection.

dkuhnert avatar Jan 27 '20 15:01 dkuhnert

@kohlmannj If you're willing to alter the syntax a bit you can get something close to what you want without having to make the types more permissive:

import React from 'react'
type CSSProperties = React.CSSProperties & {
  "@media"?: [string, React.CSSProperties]
  // can also do psuedo selectors
  ":hover"?: React.CSSProperties
}

const styles: CSSProperties = {
  fontSize: 32,
  lineHeight: 48,
  "@media": [
    "(min-width: 1280px)",
    {
      fontSize: 48,
      lineHeight: 72
    }
  ]
}

sbdchd avatar May 15 '20 23:05 sbdchd

I have experienced this as well. Why is there no way to say: "these keys must have these specific types, while also saying any other non declared keys must be of a specific type. Would be nice to be able to set a limit to how many non declared keys are allowed.

For example when making API requests that returns paginated data sometimes the data is stored under different keys. We should be able to create a PagedResponse type with a Generic to be later declared under an unknown key. Using the below example PagedResponse<Cars> and PagedResponse<Boats>. This might not be an actual useful example to use but I think demonstrates the need.

// First api response
{
    page: 0,
    totalPages: 10,
    cars: ['Honda', 'Lexus']
}

// Second api response
{
    page: 0,
    totalPages: 2,
    boats: ['Jetski', 'Mastercraft', 'Sealiner]
}

// Potentially declared as
// this type declares the first two keys as numbers and then allows one third key which is an array of Generic.
type PagedResponse<T> = {
    page: number;
    totalPages: number;
    [key: string][1]: T[]
}


kiwdahc avatar Jun 25 '20 22:06 kiwdahc

upd: this does not fully work - see comment below

it looks now it is possible to solve this as following:

type Properties = Partial<{ 
    color: string;
    backgroundColor: string;
}>


type StyleObject =
  | Properties
  | {
      [k in string]: k extends keyof Properties ? Properties[k] : StyleObject;
    };

const test: StyleObject = {
  backgroundColor: "blue",
  ":hover": {
    backgroundColor: "red",
    ":active": {
      color: "red",
    },
  },
};

const test2: StyleObject = {
  // @ts-expect-error
  backgroundColor: 123,
  ":hover": {
    backgroundColor: "red",
    ":active": {
      // @ts-expect-error
      color: 1,
    },
  },
};

zxbodya avatar Jul 21 '20 21:07 zxbodya

it looks now it is possible to solve this as following:

type Properties = Partial<{ 
    color: string;
    backgroundColor: string;
}>


type StyleObject =
  | Properties
  | {
      [k in string]: k extends keyof Properties ? Properties[k] : StyleObject;
    };

const test: StyleObject = {
  backgroundColor: "blue",
  ":hover": {
    backgroundColor: "red",
    ":active": {
      color: "red",
    },
  },
};

const test2: StyleObject = {
  // @ts-expect-error
  backgroundColor: 123,
  ":hover": {
    backgroundColor: "red",
    ":active": {
      // @ts-expect-error
      color: 1,
    },
  },
};

@zxbodya

This does not solve my situation: playground

type ServerConfig = Partial<{
    host: string
    port: number
    prefix: string
}>

type Req = { path: string, cookies: { user: 'Harrie' } }

type ServerRoutes = {
     [k in string]: k extends keyof ServerConfig ? ServerConfig[k] : (req: Req) => unknown
}

type ServerOptions = ServerConfig | ServerRoutes;


const options: ServerOptions = {
    host: 'localhost',
    port: 3000,
    prefix: '/api',
    'GET /users/me': (req) => ({ name: req.cookies.user })
}

options.host // Fine!

options['GET /users/me'](); // Error: Property 'GET /users/me' does not exist on type 'ServerOptions'.

varHarrie avatar Jul 22 '20 02:07 varHarrie

@zxbodya's approach is wrong. Note that the behavior is exactly the same if you take out the conditional type syntax, i.e.

type StyleObject =
  | Properties
  | {
      [k in string]: StyleObject;
    };

The only reason it appears to work is because of the union type (Properties | {index signature}). The conditional type never actually triggers -- I believe you can't use a string literal or literal union as an "extends" check. Look at

type Foo = {
    [k in string]: k extends "bar" ? string : number
};

declare const f: Foo;
f.a.toFixed();
f.bar.charAt(0);    // Property 'charAt' does not exist on type 'number'.(2339)

This is what you're trying to do with extends keyof Properties, and it's not possible.

thw0rted avatar Jul 22 '20 08:07 thw0rted

Yes @thw0rted, unfortunately you are right - it does not fully work :( Was too happy fining what appeared to work, so shared without checking all the parts.

However, it kinda works in one direction… which now, I am now not really sure is bug or feature:

Basically when declaring a variable like in example I have - somehow fields on it are actually checked correctly.

zxbodya avatar Jul 22 '20 08:07 zxbodya

try this out


interface CssProps {
  backgroundColor: "red";
  color: "blue";
}

type INode = Partial<CssProps> &
  Partial<{
    [prop: string]: Partial<CssProps> | INode | string | number;
  }>;

function createStyles2<ClassKey extends INode>(styles: ClassKey): ClassKey {
  return 1 as any;
}
const d10 = createStyles2({
  el1: {
    backgroundColor: "red",
    el4: {
      color: "blue",
      "&:hover": {
        backgroundColor: "red"
      }
    }
  }
});

https://www.typescriptlang.org/play/?ssl=1&ssc=1&pln=26&pc=12#code/JYOwLgpgTgZghgYwgAgMIGd0AUoHsAO6yA3gLABQyyARogNYDmeAriACaq4A2uUAXMgBEUCG0EBuClQTdeAwdS7MIEigF8KFMAE98KAJIA5XGxQBeZFjhQwwOFwA8GbHkIA+ZADIpl67fsOZJRUyADa+K4C6GBQoAwAugJWNnaOzjgE6B4APshGJii50bEgDMi5IMwAttTQksFqbvUUMKwItrggyAgicJAAyjpcEOgATE5ccJgA0hDayBAAHpDsRPmmbgAU0drD6AKokzNzAJQHR+iz80FUImDMUF0AjMhTryDa9RrkMiDRyGwngAGZAWHoQPoQQa7EajTY3BZcJ4CBFUWgIRgsdicHj8IQiMQAGh8VAgXAALCiSSEZLj5IplIJicEQkJPHwABa4ABu0EEVJZrJo9CYuFYHFkeOEokE1Ko31ZCuQ3zUJ2a5EBQIAdGSnjqKUA

ilovedesert001 avatar Jul 23 '20 12:07 ilovedesert001

@ilovedesert001, I didn't compile your example, but I've tried this one: link, which is similar and works for me.

The problem is that if I have to define multiple objects this way (i.e. creates a function that extends the type) - it's very exhausting and unreadable.

I wish that after 3 years, they would just make it possible by a simple intersection (INode of your example)...

Asaf-S avatar Aug 07 '20 13:08 Asaf-S

Well I ended up on here and this worked for me. I was trying to do:

interface Data {
  [index: string]: number;
}

interface WithSpecialProperty {
  specialProperty: string;
}

type DataWithSpecialProperty = Data & WithSpecialProperty;

const x: DataWithSpecialProperty = {
  a: 1,
  specialProperty: 'apple',
}

But got this: Type 'string' is not assignable to type 'number'

I changed it to this and it worked:

interface Data {
  [index: string]: number | string;
}

interface WithSpecialProperty {
  specialProperty: string;
}

type DataWithSpecialProperty = Data & WithSpecialProperty;

const x: DataWithSpecialProperty = {
  a: 1,
  specialProperty: 'apple',
}

Not a real solution, but it worked for what I wanted.

mfp22 avatar Oct 18 '20 05:10 mfp22

@mfp22 - Typescript's meaning is to prevent from strings to appear in any other key except 'specialProperty' in your example. Your solution differs from the 'any' type just by a little.

Asaf-S avatar Oct 18 '20 06:10 Asaf-S

Is this solved yet? I'm having the exact same issue, and haven't found a solution.

@promontis Whoa this is weird: Re: #17867 (comment)

So, type intersection allows you to do this magic thing when assigning individual properties, but not when assigning the entire object as an object literal?

It seems like TS isn't consistent in how it's thinking about the type. I'm wondering which way is "correct" and which is a bug, or happenstance, and if this technique is safe to use with forward compatibility.

Just to re-iterate, this is the behavior:

type Magic = { [key: string]: object } & { specialKey?: true };

let testObj: Magic = {};
testObj.specialKey = true; // this works
testObj.anyStringKey = {}; // this works

testObj.anyStringKey2 = true; // desired error: Type 'true' is not assignable to type 'object'.
testObj.specialKey = {}; // desired error: Type '{}' is not assignable to type 'true'

const test = testObj.specialKey; // type: true | undefined
const test2 = testObj.something; // type: object

// Error with object literal assignment:
testObj = { // Type '{ specialKey: true; }' is not assignable to type 'Magic'.
  specialKey: true, // Type 'true' is not assignable to type 'object'
};

@RyanCavanaugh Sorry to bug you on this one – but is it safe to use this individual assignment method? Is this intended? Or is this subject to change in the future?

leonardomgt avatar Nov 20 '20 15:11 leonardomgt

Another example where this is apparent is IncomingHttpHeaders in Node that is defined (approx.) like so:

interface IncomingHttpHeaders extends Record<string, string | string[] | undefined> {
    // [66 other named properties]?: string;
    'set-cookie'?: string[];
}

Actual types are here: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/http.d.ts#L7-L70 https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/http2.d.ts#L16-L21

According to the node docs ALL headers are strings except set-cookie which makes this easy to model mentally but hard (impossible?) to type out in TypeScript, setting you up for runtime exceptions via things like this:

const headers: IncomingHttpHeaders = {
  // ok according to type
  'x-forwarded-host': ['host1', 'host2'],

  // not ok according to type 👍
  'set-cookie': 'some-cookie-string'
}

So in that specific case the type is optimized for the exception (1 prop. out of 67 named plus all other indexed props.) rather than the general (string | undefined) - and it would be really great if the reverse situation we're possible 🙏 🙇 ❤️

TS Playground

tolu avatar Dec 07 '20 07:12 tolu

Has anybody had a chance to try https://github.com/microsoft/TypeScript/pull/29317 and see if it meets their needs? As you can see on that PR, it hasn't been merged yet, because they're working on certain performance problems, but maybe extra feedback would prod the team to take another look.

thw0rted avatar Dec 07 '20 09:12 thw0rted

With #26797 coming, I just want to note that a type like this, using template literal types, would be sound if it could be supported:

interface IStyle {
    padding?: number
    // ...
    [s: `:${string}` | `& ${string}`]: IStyle
}

dgreensp avatar Mar 05 '21 15:03 dgreensp

Looks like this continues to be an ask for SO folks: How to define one property type different from any others?

jcalz avatar Jun 08 '21 03:06 jcalz