33
loading...
This website collects cookies to deliver better user experience
string
, number
, boolean
, Array
or Record
. I assume that you worked with typescript in the past. This blog post starts with a brief explanation of the following concepts:typeof
, keyof
and as const
Object.keys
const array = [1, '42', null]; // typeof array: (string | number | null)[]
const item = array[0]; // typeof item: string | number | null
array.push(true); // Argument of type 'true' is not assignable to parameter of type 'string | number | null'
// ---
// you can use a type annotation to also support "boolean" values
const array: (string | number | null | boolean)[] = [1, '42', null];
array.push(true); // ok
const obj = { a: 'a', b: 'b' }; // typeof obj: { a: string; b: string; }
// obj.c = 'c'; // Property 'c' does not exist on type '{ a: string; b: string; }'
// ---
// you can use a type annotation to also support other string keys than "a" and "b"
const obj: { [Key: string]: string } = { a: 'a', b: 'b' };
obj.c = 'c'; // ok
let
and const
:let aLetString = 'test'; // type: string
const aConstString = 'test'; // type: "test"
let aLetNumber = 1; // type: number
const aConstNumber = 1; // type: 1
const takeString = (x: string) => x;
const result = takeString(aConstString); // typeof result: string
"test"
to our takeString
function? The function accepts a argument of type string
, but lets us pass something of type: "test"
without any error. Heres why:const B = 'B'; // typeof B: "B"
type A = string;
const test: A = B; // ok
// ---
type A = 'A';
const test: A = 'B'; // Type '"B"' is not assignable to type '"A"'
&
(intersection) and |
(union) operators:type Intersection = { a: string } & { b: number };
const test1: Intersection = { a: 'a', b: 1 }; // ok
const test2: Intersection = { a: 'a' }; // Property 'b' is missing in type '{ a: string; }' but required in type '{ b: number; }'
// ---
type Union = { a: string } | { a: number };
const test1: Union = { a: 'a' }; // ok
const test2: Union = { a: 1 }; // ok
type
and interface
for object types. You cannot use the &
and |
operators with interfaces, but you can with types. Personally i always use types because they have no limitations. However you can use the extends
keyword, or use a type to make a union of 2 existing interfaces:interface A { a: string }
interface B extends A { b: number }
const test1: B = { a: 'a', b: 1 }; // ok
const test2: B = { a: 'a' }; // Property 'b' is missing in type '{ a: string; }' but required in type 'B'
// ---
interface A { a: string }
interface B { a: number }
type Union = A | B;
const test1: Union = { a: 'a' }; // ok
const test2: Union = { a: 1 }; // ok
typeof
and keyof
before. as const
seems to be not used a lot in the wild, but i like it a lot.const obj = { a: 'a', b: 'b' };
type Obj = typeof obj; // { a: string; b: string; }
// ---
const obj = { a: 'a', b: 'b' };
type Key = keyof typeof obj; // "a" | "b"
// ---
const obj = { a: 'a', b: 'b' } as const;
type Obj = typeof obj; // { readonly a: "a"; readonly b: "b"; }
as const
also sets the values of the object to string literal types ("a"
and "b"
instead of string
). Lets have a closer look at the as const
keyword and a potential use case to replace enums.// https://www.typescriptlang.org/play?target=99&jsx=0#code/AQ4UwOwVwW2BhA9lCAXATgT2AbwFCiHACCAKgDQFEgAiAopdSPABKOgC+QA
enum Country {
AT,
DE,
CH,
}
// gets compiled to:
let Country;
(function (Country) {
Country[(Country['AT'] = 0)] = 'AT';
Country[(Country['DE'] = 1)] = 'DE';
Country[(Country['CH'] = 2)] = 'CH';
})(Country || (Country = {}));
Country.AT
at runtime, you will see that the value of it is the number 0
. I dont like enums that have a number as the value, because now you have this number in your database and without the enum definition in your code you are not able to tell what this number means. Enums that have string values are better imho, since they have a semantic meaning. There is another way to write a enum
which uses string values:// https://www.typescriptlang.org/play?target=99&jsx=0&ssl=5&ssc=6&pln=1&pc=1#code/AQ4UwOwVwW2BhA9lCAXATgT2AbwFCiHACCAKsALzABEZ1ANAUSACICilN7DTz8AEp2oCehAL5A
enum Country {
AT = 'AT',
DE = 'DE',
CH = 'CH',
}
// gets compiled to:
var Country;
(function (Country) {
Country["AT"] = "AT";
Country["DE"] = "DE";
Country["CH"] = "CH";
})(Country || (Country = {}));
as const
to write something like an enum
?const Country = {
AT: 'AT',
DE: 'DE',
CH: 'CH',
} as const;
const values = Object.values(Country);
type Country = typeof values[number];
// gets compiled to:
const Country = {
AT: 'AT',
DE: 'DE',
CH: 'CH',
};
as const
variant and dont need to import the enum on every place where you use this enum, but you still could if you prefer that.enum Country {
AT = 'AT',
DE = 'DE',
CH = 'CH',
}
// you always need to import the Country enum to use this function
const doSomethingWithEnum = (country: Country) => country;
doSomethingWithEnum(Country.AT); // ok
// doSomethingWithEnum('AT'); // Argument of type '"AT"' is not assignable to parameter of type 'Country'
// However doSomethingWithEnum('AT') would lead to working javascript code!
// ---
const Country = {
AT: 'AT',
DE: 'DE',
CH: 'CH',
} as const;
const values = Object.values(Country);
type Country = typeof values[number];
// intellisense support and no need to import the country object to use this function
const doSomethingWithCountry = (country: Country) => country;
doSomethingWithCountry('AT'); // ok
doSomethingWithCountry(Country.AT); // ok
// doSomethingWithCountry('US') // Argument of type '"US"' is not assignable to parameter of type '"AT" | "DE" | "CH"'
as const
can be used for other things as well. I will show you another use case within the next section.const format = (value: string | number) => {
if (typeof value === 'string') {
// value is of type string and all string functions are available within the if block
return Number.parseFloat(value).toFixed(2);
} else {
// value is of type number and all number functions are available within the else block
return value.toFixed(2);
}
};
const a = { value: 'a' };
const b = { value: 42 };
type AOrB = typeof a | typeof b;
const takeAOrB = (aOrB: AOrB) => {
if (typeof aOrB.value === 'string') {
const { value } = aOrB; // typeof value: string
} else {
const { value } = aOrB; // typeof value: number
}
};
const a = { a: 'a' };
const b = { b: 42 };
type AOrB = typeof a | typeof b;
const takeAOrB = (aOrB: AOrB) => {
if ('a' in aOrB) {
const { a } = aOrB; // typeof a: string
} else {
const { b } = aOrB; // typeof b: number
}
};
kind
or type
property which then can be used to distinguish between different types (this kind
property could also be used in a switch case):const a = { kind: 'a' as const, value: 'a' };
const b = { kind: 'b' as const, value: 42 };
type AOrB = typeof a | typeof b;
const takeAOrB = (aOrB: AOrB) => {
if (aOrB.kind === 'a') {
const { value } = aOrB; // typeof value: string
} else {
const { value } = aOrB; // typeof value: number
}
};
any
. Both are not a good solution for it, since the return value will not have the proper type.type Primitive = string | number | boolean;
const identity = (
x: Primitive | Array<Primitive> | Record<string, Primitive>,
) => x;
const test1 = identity('a'); // typeof test1: Primitive | Primitive[] | Record<string, Primitive>
const test2 = identity(1); // typeof test2: Primitive | Primitive[] | Record<string, Primitive>
any
would save you from writing a union of every possible type, but leads or less to the same result:const identity = (x: any) => x;
const test1 = identity('a'); // typeof test1: any
const test2 = identity(1); // typeof test2: any
const identity = <T>(x: T) => x;
const test1 = identity<string>('a'); // typeof test1: string
const test2 = identity<string>(1); // Argument of type 'number' is not assignable to parameter of type 'string'
const test3 = identity<number>(1); // typeof test3: number
const test4 = identity<boolean>(true); // typeof test4: boolean
identity
function in the examples above. There are 2 views on this:any
thing 😉. The type is only known once the function is called with some argument. Your co-worker can even rely on the type inference from typescript and dont specify a type at all:const identity = <T>(x: T) => x;
const test1 = identity('a'); // typeof test1: "a"
const test2 = identity(1); // typeof test2: 1
const test3 = identity(true); // typeof test3: true
"a"
instead of string
1
instead of number
true
instead of boolean
extends
keyword. Lets see 2 examples on how we could restrict the identity function to only accept a string or union type:const identity = <T extends string>(x: T) => x;
const stringTest = identity('a'); // typeof stringTest: "a"
const numberTest = identity(1); // Argument of type 'number' is not assignable to parameter of type 'string'
// ---
const identity = <T extends 'A' | 'B' | 'C'>(x: T) => x;
const test1 = identity('A'); // typeof stringTest: "A"
const test2 = identity('D'); // Argument of type '"D"' is not assignable to parameter of type '"A" | "B" | "C"'
Object.keys
) has not the correct typings. The problem:const obj = { a: 'a', b: 'b' };
type Obj = typeof obj; // { a: string; b: string; }
type Key = keyof Obj; // "a" | "b"
const keys = Object.keys(obj); // typeof keys: string[]
keys
to be: ("a" | "b")[]
. Typescript inferred a single key correctly: "a" | "b"
, but the type of the return value string[]
of Object.keys
seems wrong. Now that we know what the problem is, we can try to write our own wrapper function with proper typing:const objectKeys = <T extends Record<string, unknown>>(obj: T) =>
Object.keys(obj) as Array<keyof T>;
const obj = { a: 'a', b: 'b' };
const keys = objectKeys(obj); // typeof keys: ("a" | "b")[]
type Key = typeof keys[number]; // "a" | "b"
string
or a Array
as argument. Since typescript has really good type inference, it will know that only a
and b
are valid keys for this object and pass back this type to us: ("a" | "b")[]
. If you would add a c
key to the object, it will pass you back: ("a" | "b" | "c")[]
without any changes on the implementation of the function and without writing a type yourself. Thats the power of generics. 😍const omit = (obj: Record<string, unknown>, keysToOmit: Array<string>) =>
Object.fromEntries(
Object.entries(obj).filter(([k]) => !keysToOmit.includes(k)),
) as Record<string, unknown>;
const obj = { a: 'a', b: 'b' };
omit(obj, ['c', '42']); // ['c', '42'] is a valid argument, but it should not be valid!
const partialObj = omit(obj, ['a']); // typeof partialObj: Record<string, unknown>
const a = partialObj.a; // typeof a: unknown
const b = partialObj.b; // typeof b: unknown
const c = partialObj.c; // typeof c: unknown
Record<string, unknown>
which basically means: some unknown object. a
and b
on the return type are typed as unknown
. If we try to access c
which was not even present on the input, we get unknown
and no error. 😔const omit = <T extends Record<string, unknown>>(
obj: T,
keysToOmit: Array<keyof T>,
) =>
Object.fromEntries(
Object.entries(obj).filter(([k]) => !keysToOmit.includes(k)),
) as Record<string, unknown>;
const obj = { a: 'a', b: 'b' };
omit(obj, ['c']); // Type '"c"' is not assignable to type '"a" | "b"'
const partialObj = omit(obj, ['a']); // typeof partialObj: Record<string, unknown>
const a = partialObj.a; // typeof a: unknown
const b = partialObj.b; // typeof b: unknown
const c = partialObj.c; // typeof c: unknown
keysToOmit
argument. But the type of the return value is still: Record<string, unknown>
. Also we still get unknown
for a
, b
and c
. 😔const omit = <T extends Record<string, unknown>>(
obj: T,
keysToOmit: Array<keyof T>,
) =>
Object.fromEntries(
Object.entries(obj).filter(([k]) => !keysToOmit.includes(k)),
) as Partial<T>;
const obj = { a: 'a', b: 'b' };
const partialObj = omit(obj, ['a']); // typeof partialObj: Partial<{a: string; b: string; }>
const a = partialObj.a; // typeof a: string | undefined
const b = partialObj.b; // typeof b: string | undefined
const c = partialObj.c; // Property 'c' does not exist on type 'Partial<{ a: string; b: string; }>'
keysToOmit
argument, but now also add as Partial<T>
to the end of the omit function, which makes the type of the return value a little more accurate. a
and b
are typed with string | undefined
which is somehow correct. But we now get a error when we try to access c
. Still not perfect. 😔const omit = <T extends Record<string, unknown>, K extends Array<keyof T>>(
obj: T,
keysToOmit: K,
) =>
Object.fromEntries(
Object.entries(obj).filter(([k]) => !keysToOmit.includes(k)),
) as Omit<T, K[number]>;
const obj = { a: 'a', b: 'b' };
const partialObj = omit(obj, ['a']); // typeof partialObj: Omit<{ a: string; b: string; }, "a">
const a = partialObj.a; // Property 'a' does not exist on type 'Omit<{ a: string; b: string; }, "a">'
const b = partialObj.b; // typeof b: string
const c = partialObj.c; // Property 'c' does not exist on type 'Omit<{ a: string; b: string; }, "a">'
b
is a valid key and it is typed as string
which is also correct. Trying to access a
on the return value will result in an error, because it was removed by our function. Trying to access c
will also result in an error, since it was not even present on the input object. 😍