TypeScript Notes
Compiled by Jeremy Kelly
www.anthemion.org
Printed UNKNOWN
These are my TypeScript notes, covering version 5. If you find a mistake, please let me know.
The example code uses a new notation I am developing. More on that soon.
This page includes a two-column print style. For best results, print in landscape, apply narrow margins, change the Scale setting in your browser’s print options to 70%, and enable background graphics. Firefox and Chrome each have their own set of printing bugs, but one of them usually works.
Contents
- tsc
- Help and setup options
- General build options
- Library and module input options
- Source input options
- Declaration output options
- Module output options
- Source output options
- Source map output options
- Error checking options
- Build mode options
- tsconfig.json
- Simple types
- any and unknown
- null and undefined
- Literal types
- Template literal types
- Intrinsic string literal utilities
- Arrays
- Tuples
- Type operations
- Type aliases
- Type unions
- Type intersections
- typeof
- keyof
- Indexed access types
- Mapped types
- Conditional types
- Type narrowing
- Type predicates
- Type assertions
- const assertions
- Object types
- Interfaces
- Interface properties
- Optional interface properties
- Read-only interface properties
- Callable interfaces
- Call signatures
- Index signatures
- Construct signatures
- Merging interfaces
- Extending interfaces
- Classes
- Class properties
- Class property initialization
- Optional class properties
- Read-only class properties
- Parameter properties
- Access modifiers
- Extending classes
- Abstract classes
- Overriding class members
- override
- Implementing interfaces
- Generics
- Default type parameters
- Generic type constraints
- Generic variance annotations
- Functions
- Function assignability
- Optional and default parameters
- void and never return types
- Function overloads
- Enumerations
- Constant enumerations
- Declaration merging
- General sources
tsc
The typescript
package includes a transpiler tsc
, which translates TypeScript into JavaScript. Tools like Babel can also transpile it, and runtimes like Deno and Bun can run it directly, without transpilation.
tsc
supports numerous command line options, but these are typically added to tsconfig.json
files, rather than being passed on the command line. TypeScript filenames or globs can also be passed as tsc
arguments. tsconfig.json
is ignored when source files are specified this way.
Sources
tsc CLI OptionsHelp and setup options
-
-h
-
--help
- Lists commonly-used compiler options.
-
--all
- Lists all compiler options.
-
--init
-
Creates a
tsconfig.json
file in the current folder.
General build options
-
-p
-
--project
,-p path | pathName
-
Builds using the specified configuration file, or with the
tsconfig.json
file in the specified folder. -
--showConfig
-
Displays the content of the
tsconfig.json
that would be used during a build, without actually building. -
--diagnostics
-
--extendedDiagnostics
- Displays compilation performance data, including total build time.
Library and module input options
-
--lib versionList
-
tsc
provides declaration files that describe standard JavaScript functions and containers, plus the DOM API. By default, it uses the--target
setting to decide which version of these libraries to include. This option can be used to select a different version, or to include some libraries while excluding others. Library versions can also be selected by configuring the package manager. -
--noLib
-
Stops
tsc
from automatically including library declaration files, even if--lib
is set. The developer must provide their own declarations for types likeObject
,Function
,String
, et cetera. -
--moduleDetection
-
Determines how
tsc
decides whether a source file is a module:-
auto
is the default. It causes files to be considered modules if they containimport
orexport
statements, or iftype
is set tomodule
withinpackage.json
when--module
isnode16
ornodenext
, or if the file is a JSX file when--jsx
isreact-jsx
; -
legacy
causes files to be considered modules if they containimport
orexport
statements; -
force
causes all non-declaration files to be considered modules.
-
-
--moduleResolution
-
Determines how TypeScript resolves modules at build time.
The runtime or build tool that consumes the compiled
js
files is known as the host. The module name (and sometimes path) inside an import is called a module specifier. The process of finding the specified file is called module resolution. Different hosts resolve specifiers in different ways, and some allow paths and file extensions in specifiers, while others disallow them.tsc
does not modify specifiers when producingjs
output, so imports must use the specifiers expected by the host, and this flag must be set in a way that reproduces the host’s resolution process. This allows run-time module resolution to be checked at build time, and it also allows module types to be loaded and checked.Though
classic
is the default setting for some--module
selections, it will be deprecated in TypeScript 6. Node projects should usenode16
ornodenext
, which are the defaults for the corresponding--module
selections. Other configurations are more complex; these are addressed the TypeScript documentation. -
--rootDirs pathList
-
Defines one or more folders that are implicitly merged (along with their subfolders) with the source folder hierarchy when resolving import paths. The modules in the various hierarchies can then be specified as if they were part of a single directory structure. This should not be confused with
--rootDir
, which affects the folder hierarchy created within the--outDir
folder. -
--moduleSuffixes suffixList
- Defines a list of suffixes to be appended automatically to module specifiers when resolving these to files.
-
--allowArbitraryExtensions
- Disables the build error that normally occurs when a module specifier includes an unexpected file extension.
-
--resolveJsonModule
-
Allows JSON files to be imported as JavaScript objects:
import Data from "./data.json";
-
--traceResolution
- Displays build output that explains why each module was included and how it was resolved.
-
--noUncheckedSideEffectImports
-
Side effect imports are imports that bring nothing into scope. By default,
tsc
ignores these if the specified module is not found. This flag produces a build error instead. -
--isolatedDeclarations
- By default, TypeScript allows variable types and function signatures to be inferred from their definitions. Those definitions can themselves rely on inferred types (possibly from other modules), which causes declaration generation to take more time. When this flag is set, exported variables and functions produce build errors if their declarations would require non-trivial type inference.
Sources
Supporting lib from node_modules The module output format The module compiler option Modules - Choosing Compiler OptionsSource input options
-
--allowJs
- Allows plain JavaScript files to be used within the project. By default, importing a JavaScript file produces a build error.
-
--checkJs
-
Enables error checking for plain JavaScript files processed with
--allowJs
. -
--listFiles
-
--explainFiles
-
--listFilesOnly
-
Lists all files that were read during the build, including external files referenced by internal project files.
--explainFiles
also gives the reason for each file’s inclusion.--listFilesOnly
lists them without actually building.
Declaration output options
-
--declaration
-
Produces type definition files in addition to normal build output. These files use the
d.ts
,d.mts
, andd.cts
file extensions. Like C/C++ headers, they describe the public interface of each compiled module, including any types it chooses to export. -
--emitDeclarationOnly
-
Causes
d.ts
files to be generated without other build output. -
--declarationDir path
-
Specifies a separate output folder for
d.ts
files. -
--declarationMap
-
Produces source maps that link
d.ts
files to thets
files that generated them. -
--stripInternal
-
Causes any export that includes an
@internal
directive in its JSDoc comment to be excluded from the declaration output.
Module output options
-
--module value
-
Determines the module type to be produced in
js
file output. This ensures module compatibility with the host, and it allows TypeScript to identify module errors that would otherwise produce run-time exceptions.The default for this setting is
es6
(also specified withes2015
), orCommonJS
if--target
ises5
.es2020
adds support for dynamic imports andimport.meta
metadata.es2022
adds top-levelawait
.esnext
andnodenext
target current versions of JavaScript and Node, and will incorporate new features as those are standardized.Node projects should use
node16
ornodenext
, notcommonjs
or any of the ECMAScript options. Node now supports both CommonJS and ECMAScript modules, sotsc
uses a source file’s extension or thepackage.json
in its folder or an ancestor folder when choosing the module type.cts
andmts
files are considered to be CommonJS or ECMAScript modules, respectively. Ats
file is considered to be an ECMAScript module if thetype
property in itspackage.json
is set tomodule
, otherwise it is treated as CommonJS. This decision affects the file’s transpilation, plus its output file extension. Node uses an equivalent process to determine the module type of compiledcjs
,mjs
, andjs
files. -
--verbatimModuleSyntax
-
Prevents ES6
import
andexport
statements from being converted to CommonJS equivalents in the output, and vice versa. TypeScript files therefore must use the syntax that corresponds to the module type expected by the host, whether that isimport
andexport
, or TypeScript’s version ofrequire
andmodule.exports
:import el = require(path); ... export = {el1, el2, ...};
TypeScript also allows types and interfaces to be imported from modules; imports that only target these elements are called type-only imports. Because TypeScript types are not used in JavaScript,
tsc
can omit these imports fromjs
file output, which prevents the side effects from being performed. However, this flag ensures that all modules are imported in thejs
output, except those explicitly defined as type-only withimport type
.
Sources
Modules - TheorySource output options
-
--target value
-
Specifies the JavaScript version to be used when generating
js
files. The default ises5
.esnext
uses the highest version supported by this version of TypeScript. -
--outDir path
-
Specifies the destination folder for output files and folders, including compiled
js
files,d.ts
files, source maps, and others. The source folder hierarchy will be reproduced inside this folder. When this option not used, output files are placed in the source folders alongside thets
files that generated them. -
--rootDir path
-
When combined with
--outDir
, this option specifies the root folder relative to which source file paths will be compared when reproducing the source folder hierarchy inside the--outDir
folder. Only the hierarchy contained by path will be reproduced in--outDir
. This should not be confused with--rootDirs
, which defines extra paths for module resolution. -
--outFile pathName
-
Causes all non-module files to be concatenated into the specified output file.
--module
must be set toNone
,System
, orAMD
. -
--listEmittedFiles
- Lists all output files that were generated by the compiler.
-
--emitBOM
- Adds UTF-8 byte-order marks (BOMs) to all output files.
-
--newLine end
-
Determines whether output files end lines with
lf
orcrlf
. The default islf
. -
--removeComments
-
Comments in TypeScript files are normally included in
js
file output. This flag removes them. -
--jsx value
-
--jsxFactory value
-
--jsxFragmentFactory value
-
--jsxImportSource value
-
These settings determine whether and how JSX is transpiled in the
js
file output. They can be changed in specific files by adding@jsxRuntime
,@jsx
,@jsxFrag
,@jsxImportSource
directives to comments inside them.
Source map output options
-
--sourceMap
-
Produces
js.map
andjsx.map
source map files that associatejs
files with thets
files that generated them. This flag adds comments to the compiledjs
files; those comments reference the map files, which then reference thets
files. This allows debuggers to show the original TypeScript while running the compiled JavaScript. -
--inlineSourceMap
-
Produces source map data, but instead of storing it in source map files, this flag adds it to the
js
file comments that would reference those files, if--sourceMap
were used instead. -
--inlineSources
-
Adds
ts
file content directly to the source map files, if--sourceMap
is used, or to the source map comments, if--inlineSourceMap
is used.
Error checking options
-
--noEmitOnError
- Prevents output file generation if build errors are reported.
-
--strict
-
Collectively enables these flags, while allowing them to be disabled individually:
-
--alwaysStrict
-
Enables strict mode for all files, and adds
"use strict"
to alljs
output. -
--strictNullChecks
-
TypeScript offers special types for
undefined
andnull
, which can be added to type unions that qualify variables or other elements, if those are possible values. This flag forces the developer to track these types wherever they are used. It does this by producing build errors when:-
undefined
ornull
is assigned to an element that does not include the relevant type in its union; -
An element with such types is dereferenced without first checking for
undefined
ornull
.
-
-
--noImplicitAny
-
TypeScript assumes a type of
any
if a type has not been specified, and cannot be inferred. This flag produces a build error instead. -
--noImplicitThis
-
Produces a build error if properties are read from
this
in a context in which the type ofthis
is not known. -
--strictPropertyInitialization
-
Produces a build error if a class field is declared without being initialized in the body or assigned in the constructor, unless
undefined
is part of the field’s type. This option requires that--strictNullChecks
be enabled as well. -
--strictFunctionTypes
- Function types can reference function instances with different parameter and return types. This can happen when an instance is assigned to a variable or property, when a method-containing object is referenced through an interface, or when a subclass overrides properties or methods in an ancestor class. For type safety, if the parameter or return types are not identical, parameters should contravary, becoming more general in the instance or subclass. Return types should covary, becoming more specific in the instance or subclass. This flag produces a build error if a variable or property that references a function violates these rules. It does not produce an error if method violates them, however.
-
--strictBindCallApply
-
Produces a build error if
call
,bind
, orapply
is invoked with arguments that fail to match the parent function. -
--useUnknownInCatchVariables
-
The exception parameter in a
catch
statement can be explicitly typed asany
orunknown
. By default, if it is not explicitly typed, it is considered to beany
. This option causes it to be consideredunknown
instead. -
--noUncheckedIndexedAccess
-
Indexers can be used to read properties or array elements that do not exist, producing
undefined
results. This flag causes every indexer to return a union of its declared return type plusundefined
, unless the indexed property or element is known to exist. -
--strictBuiltinIteratorReturn
-
Causes iterators to produce values that are qualified with a concrete type plus
undefined
, rather thanany
. -
--noImplicitOverride
-
Produces a build error if a subclass duplicates a property or method in an ancestor class without declaring itself to be an
override
. -
--noImplicitReturns
-
Produces a build error if any function execution path lacks an explicit return, unless the return type is
undefined
orvoid
. -
--noFallthroughCasesInSwitch
-
Produces a build error if any
case
statement lacks abreak
,return
, orthrow
. -
--noUnusedLocals
-
--noUnusedParameters
- Produces a build error if any local variable or function parameter is defined but not used.
-
--allowUnreachableCode
-
--allowUnusedLabels
-
By default,
tsc
produces warnings when it finds unreachable code or unused labels. Setting this option totrue
suppresses those warnings. Setting it tofalse
produces build errors. -
--exactOptionalPropertyTypes
-
Keys in object types can be suffixed with question marks to show that their presence is optional. Originally, these types matched objects that lacked the optional properties entirely, as well as ones that explicitly set the properties to
undefined
. When this flag is set, objects with explicitlyundefined
properties no longer match these types, and optional properties cannot be assigned withundefined
when referenced through the types. -
--downlevelIteration
- Causes code points containing multiple code units to be handled correctly when ES6 iteration functionality is transpiled to older versions.
Sources
Strict Builtin Iterator ChecksBuild mode options
Incremental builds can be performed by dividing a project into smaller subsidiary projects. Each subsidiary has its own tsconfig.json
file, which must include --composite
. A single parent project references the others with project references, defined in the references
property within its tsconfig.json
. The parent can then be built with --build
or -b
, which enable build mode. This mode builds the subsidiary projects if they are out of date, then loads their declaration files and builds the parent. If the parent has no source files of its own, it should define an empty files
property, to prevent subsidiary files from being built twice.
Build mode options include:
-
-b
-
--build [pathList]
-
Builds the projects referenced by pathList, with each element being the path to a
tsconfig.json
file or to a folder that contains one. If no pathList is provided, the current folder is assumed.Before each pathList project is built, its project references are checked, and those projects are built, if they are out of date. This flag implicitly sets
--noEmitOnError
for all projects. -
--composite
-
Allows this project to be included as a project reference when another project uses build mode. In particular, this flag enforces requirements that allow
tsc
to determine whether this project is up to date, or whether it needs to be rebuilt. It also implicitly enables--declaration
. All source files in this project must be listed infiles
orinclude
withintsconfig.json
.When specified on the command line, this option can only be disabled, by passing
false
ornull
as an argument. It can be enabled only withintsconfig.json
. -
--force
-
Builds all files in the project, even those that have not changed. This may be necessary when
d.ts
files or other build output is committed to source control, if the source control app does not preserve timestamps. Must be combined with--build
. -
--verbose
-
Produces verbose log output during the build. Must be combined with
--build
. -
--clean
-
Deletes all output files from the project. Must be combined with
--build
.
Sources
Project Referencestsconfig.json
Source files can be passed to tsc
as command line arguments, but a tsconfig.json
file is typically used instead:
{
"include": ["Source", "Test"],
"exclude": ["Test/Templ"],
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true
},
"$schema": "https://json.schemastore.org/tsconfig"
}
When invoked without source file arguments, tsc
:
-
Builds the project with the
tsconfig.json
file that was specified on the command line with--project
; or, -
Builds the projects with
tsconfig.json
files that were specified with the--build
option; or, -
Builds with the first
tsconfig.json
it finds, starting with the current folder and moving up through that path; or, -
Displays the help, or produces an error, if
--build
was specified.
The tsconfig/bases GitHub project offers tsconfig.json
files customized for various platforms and frameworks, including Node and create-react-app. The TypeScript documentation also provides recommendations for various configurations.
Some of the top-level properties supported by tsconfig.json
include:
-
extends
-
The path to another
tsconfig.json
from which this file should inherit. Any properties duplicated in this file override those in the parent, but different properties are overridden in different ways. ThecompilerOptions
object augments the one in the parent, creating a union of the two property sets while giving precedence to the settings in this file. By contrast, thefiles
,include
, andexclude
arrays are replaced, causing all parent elements to be lost. Properties storing relative paths continue to be resolved relative to the file that defined them.references
is not inherited in any form. -
files
-
An array of paths specifying files to be built in this project. Unlike
include
, these paths cannot include wildcards. -
include
-
An array of file globs specifying files to be built in this project.
*
matches any number of characters,?
matches exactly one, and**/
matches all folders in the specified parent. If the last globs segment contains no wildcard or file extension, it is assumed to represent a folder, and all files in that folder are built according to their file extensions. -
exclude
-
An array of file globs specifying files to be excluded from those matched by
include
. The same wildcards can be used. Thefiles
array is not filtered by this property. -
references
-
An array of objects, each with a
path
property that references atsconfig.json
file, or a folder that contains that file. The referenced projects (which must define--composite
) will be built when this project uses build mode. -
compilerOptions
-
An object containing
tsc
option names and arguments. Most command line options can also be specified here. Option names are not prefixed with dashes, and options that are implicitly enabled when passed on the command line must be explicitly set totrue
.
Sources
TSConfig Reference What is a tsconfig.jsonSimple types
JavaScript has a basic type system, but it is extremely permissive, and it does not allow comprehensive typechecking at build time. TypeScript solves these problems by superimposing its own extensible type system, which it checks and then removes during the build. Types in this system do two things:
- They determine how instances can be assigned. This covers initialization and assignment to variables and properties, plus the passing of data to and from functions;
-
They determine how instances can be used. This includes whether they must be checked for
null
orundefined
beforehand, what properties or methods can be read from them, and whether they can be invoked as functions, indexers, or constructors.
Type annotations associate types with variables, properties, function parameters and return values, classes, and other elements. Annotations typically follow the element’s name, with a colon between. Unlike the JavaScript classes that wrap them, primitive types have lowercase names that match the strings returned by the typeof
operator:
let oID: string;
TypeScript will infer the type automatically in many cases:
// Type `string`:
let oID = "ID000";
At build time, wherever a value is assigned to another element, or passed to or from a function, TypeScript checks the assignability of the associated types. Very generally, a type is assignable to another if an instance of the first type has all the qualities expected from instances of the second type. Primitive types are never assignable to each other, even though the same types would often be converted automatically by JavaScript.
TypeScript produces a build error whenever it finds that a type has been assigned to an incompatible type, or used in a way that is invalid for that type:
// Build error: "Type 'number' is not assignable
// to type 'string'":
oID = 100;
Like most TypeScript features, this typechecking does not affect the compiled js
file output.
any and unknown
If no type is provided, and if one can’t be inferred, an element will be typed as any
. This produces a build error if --noImplicitAny
is set, but elements can also be explicitly typed this way. any
can be assigned with any other type, and can be assigned to any type except never
. If the operation is invalid, a run-time error will result, rather than a build error.
If any
is inferred for a variable, TypeScript will track each assignment, and produce a build error if an operation is obviously invalid for the current value’s type. This limited typechecking is sometimes called evolving any. It is not used when elements are explicitly typed as any
.
Elements can also be explicitly typed with unknown
. Where explicit any
allows almost any use, unknown
allows no use, other than assignment to another any
or unknown
. In all other cases, unknown
must be narrowed or asserted to a different type before it is used. unknown
is never inferred, though it is assumed for catch
statement parameters when --useUnknownInCatchVariables
is set.
null and undefined
TypeScript defines special types for null
and undefined
, which are unique global instances in JavaScript. If either is part of an element’s type, and if --strictNullChecks
is enabled, the element must be checked for null
or undefined
before it is dereferenced, or a build error will result.
This can be avoided by appending the non-null assertion operator !
to the element, which removes null
and undefined
from its type. No run-time check is performed:
function Get(aIdx: number|undefined) {
const oIdxNext = aIdx! + 1;
...
Literal types
A literal type is one that constrains an element to a specific value within a primitive type. Literal types can be combined in unions to produce something like an enumeration:
function Ready(aCd: "NEXT"|"ALL") {...
Literals can also be combined with non-literal types:
function Adv(aCt: number|"STOP") {...
However, neither NaN
nor Infinity
can be used as literal types.
When an unannotated const
variable is initialized with a primitive, the restrictive literal type is inferred, rather than the general primitive type:
// Type `"STOP"`:
const oCt = "STOP";
When the same is done with a let
variable, the primitive type is selected. This prevents the variable from satisfying a literal type constraint, even if the assignment immediately precedes the variable’s use:
let oCt = "STOP";
// Build error: "'string' is not assignable...":
Adv(oCt);
This can be fixed by asserting the variable to fit the literal type:
type tCt = number|"STOP";
Adv(oCt as tCt);
Template literal types
Where a string literal type can be assigned only with that same literal, a template literal type can be assigned with a range of string literals that match its pattern. Like a JavaScript template literal, it is surrounded with backquotes, and contains zero or more parameters, each inside curly braces prefixed with dollar signs:
type tNameCk = `Ck${string}`;
let oNameCk: tNameCk = "CkReady";
// Build error: "Type '"ID"' is not assignable...":
oNameCk = "ID";
Literal portions of the pattern must be matched exactly. Each parameter contains a type expression that matches zero or more substrings:
-
When
string
,boolean
,number
, orbigint
are specified, the parameter matches all the strings that represent values with that type. Forstring
, this includes the empty string:oNameCk = "Ck";
-
When a literal string, boolean, number, or
bigint
type is specified, the parameter matches the string representation of that literal, much as if it were not parameterized; -
When
null
orundefined
are specified, the parameter matches “null” or “undefined”; - When a union is specified, the parameter matches any element within the union.
Intrinsic string literal utilities
String literal types can be passed to the generic Lowercase
, Uppercase
, Uncapitalize
, or Capitalize
utility types to generate new literal types that vary in case:
type tTextStat = "act"|"wait";
type tNameCtStat = `Ct${Capitalize<tTextStat>}`;
let oNameCt: tNameCtStat = "CtAct";
oNameCt = "CtWait";
These intrinsic utilities are not distributed in a library; they are built into TypeScript.
Arrays
TypeScript types all the elements of an array the same way. If it is necessary to store different types in different elements, the element type must be a union of those types, or perhaps unknown
or any
.
Array types are declared by specializing the Array
generic with the element type:
let oIDs: Array<string>;
or by appending brackets to that type:
let oIDs: string[];
Multidimensional arrays are declared by adding more brackets.
If no element type is specified, and if it cannot be inferred, the array will be typed with evolving any:
let oEls = [];
...
oEls = ["A", "B"];
By default, array indexer return values are typed with the element type, even though these could return undefined
. Setting the --noUncheckedIndexedAccess
flag causes these to return a union of the element type and undefined
.
Tuples
Arrays are sometimes used to represent tuples in JavaScript. TypeScript formalizes this convention while distinguishing tuples from its own arrays. In particular, though TypeScript tuples can store multiple types, they cannot be resized, even though they are stored as arrays in the compiled output.
Tuples are annotated by listing the element types within braces:
let oBatch: [string, number];
Tuple types typically cannot be inferred from initializers, because an array literal containing multiple types is interpreted as an array of the union of those types:
// Array `(string|number)[]`:
let oRefs = ["TOP", 0];
Asserting the literal as const
produces a read-only tuple of literal types, however:
// Tuple `readonly ["TOP", 0]`:
let oRefs = ["TOP", 0] as const;
Type operations
Type aliases
Type aliases reference type declarations, allowing them to be reused. They resemble variable definitions, but the type
keyword takes the place of let
or const
, and the ‘value’ is a type expression:
type tKey = string|null;
Aliases can be used anywhere a type would be used:
type tData = {ID: string, Ct: number};
function Upd(aData: tData) {...
including other alias declarations:
type tReq = {Addr: string, Data: tData};
Aliases are interchangeable with other types and aliases that are declared the same way:
const oDataDef = {ID: "001", Ct: 1} as const;
Upd(oDataDef);
Aliases that declare object types can be used somewhat like interfaces, but they cannot be merged like interface declarations, nor can they be extended like interfaces. They can be combined with type intersections to produce new types:
type tPropWt = {Wt: number};
type tPropHgt = {Hgt: number};
const oProps: tPropWt&tPropHgt = {Wt:100, Hgt:200};
Type unions
Two or more types can be combined with pipes to form a union, which can be assigned with any of the contained types:
let oLoc: number|string;
A pipe can also precede the first type without changing the union:
let oLoc: |number|string;
TypeScript will infer a union type if some conditional operation initializes an element with multiple types:
// `number|"DONE"`:
const oIdx = (oCt > 0xFF) ? "DONE" : oCt;
Type intersections
Two or more types can be combined with ampersands to form an intersection. Where a union can be assigned with any of its type elements, an intersection must be assigned with all of them together. When object types are combined, this effectively defines a new type that contains all the members of the input types:
type tReq = {Addr: string, Cd: number};
type tReset = {Reason: string};
type tReqReset = tReq&tReset;
const oReq: tReqReset = {
Addr: "ALT",
Cd: 100,
Reason: "Reset task"
};
Some intersections cannot be satisfied by any instance; these produce the never
type. If two primitives are intersected, or if a primitive type is intersected with an object type, never
will result. If two interfaces share a member with the same name and type, that member will be found in their intersection; if the members have different types, the member will instead be typed as never
. The same conflict would produce a build error if the interfaces were merged.
Intersection distributes over a union, so the intersection of one type with a union is equal to the union of the intersection of that type with every union element:
A!(B|C) = (A&B)|(A&C)
Because never
is implicitly removed from unions, intersections can be used to filter them:
type tVals = string|number;
type tAttrs = string|object;
type tProps = string|number|object;
// `string`:
type tValsAttr = tVals&tAttrs;
// `string|number`:
type tValsProp = tVals&tProps;
Intersections can be combined with object type unions to specify a range of types that share certain members, while differing in other ways:
type tOpt = {Stg: string};
type tOptX = {X: number, Type: "X"};
type tOptY = {Y: number, Type: "Y"};
type tOpts = tOpt&(tOptX|tOptY);
function Run(aOpts: tOpts) {
if (aOpts.Type === "X") {
const oX = aOpts.X;
...
typeof
JavaScript’s typeof
operator describes types only in the most general way. Object and function types are identified merely as “object” or “function”.
Though TypeScript uses JavaScript’s typeof
during type narrowing, it also provides its own typeof
operator. This typeof
is usable only within type expressions, so it is evaluated only at build time. Like JavaScript’s operator, it returns the type of its variable or property operand, but it returns a detailed TypeScript type that can be used in other type operations:
let oVal: number;
// Type `number`:
let oNum: typeof oVal;
keyof
The keyof
type operator accepts an object type, and returns a union of string, number, or symbol literal types containing all the object’s keys. This new type can be used to constrain a variable or parameter to values that are valid keys for that object.
For example, the bracket notation allows a property to be dereferenced indirectly, with a string value that stores the property name. If an object type declares no index signature, dereferencing this way produces an any
result, causing a build error if --noImplicitAny
is set:
interface jResi {
Time: Date;
First: number;
Mid: number;
Last: number;
};
function Res(aResi: jResi, aName: string) {
// Build error: "implicitly has an 'any' type because...
// 'string' can't be used to index type 'jResi'":
let oRes = aResi[aName];
...
Adding a general index signature would avoid this, but it would also allow undefined properties to be read from the object:
interface jResi {
...
[aName: string]: Date|number;
};
Instead, the bracket argument can be narrowed to the set of valid property names with keyof
:
function Res(aResi: jResi, aName: keyof typeof aResi) {
let oRes = aResi[aName];
...
Indexed access types
Much the way bracket notation can be used to read a named value from an object instance, TypeScript allows it to extract a property type from an object type. This is an indexed access type:
type tRec = {
ID: number,
Cd: "NEW"|"READY",
Time: Date,
Data: string|object
};
// `string|object`:
type tData = tRec["Data"];
String literals can be used to specify keys, but this operation happens at build time, so these are actually literal types. String instances cannot be used:
const oKeyTime = "Time";
// Build error: "'oKeyTime' refers to a value, but is
// being used as a type here...":
type tTime = tRec[oKeyTime];
Only types can be passed to the indexer:
type tKeyTime = "Time";
// `Date`:
type tTime = tRec[tKeyTime];
Any type can used, so long as it references one or more properties. An indexer type that matches several keys with different types produces a union of those types:
// `"NEW"|"READY"|Date`:
type tCdOrTime = tRec["Cd"|"Time"];
Arrays can be dereferenced with number
, and nested objects can be dereferenced by appending more indexers:
type tRecs = tRec[];
// `number`:
type tID = tRecs[number]["ID"];
Mapped types
Mapped types transform existing object types into new object types, using a syntax somewhat like an index signature:
type tKeys = "A"|"B"|"C";
// Equivalent to:
//
// type jCks = {
// A: boolean;
// B: boolean;
// C: boolean;
// };
//
type jCks = {
[zKey in tKeys]: boolean;
};
The indexer contains an expression of the form el in union
, with union often being a union of literal types representing object keys. The indexer is annotated with a type or type expression. TypeScript iterates union, assigns each iteration result to el, and adds a property to the output object type with the original name and the annotated type.
keyof
extracts a union of key literal types from an existing type. Dereferencing the original type with el produces the original property type, allowing it to be extended in the indexer annotation:
interface jAcct {
ID: string;
Name?: string;
Bal: number;
}
// Equivalent to:
//
// type jAcctNull = {
// ID: string|null;
// Name?: string|undefined|null;
// Bal: number|null;
// };
//
type jAcctNull = {
[zKey in keyof jAcct]: jAcct[zKey]|null;
};
The indexer can be prefixed with readonly
or suffixed with ?
to make the new properties read-only or optional. Note that these modifiers occupy the same positions they would occupy within an ordinary property declaration. Modifiers can also be prefixed with -
to remove read-only or optional from the new properties. Defining the mapped type as a generic allows these changes to be applied to any existing type:
type tImmutReq<x> = {
readonly [zKey in keyof x]-?: x[zKey];
}
// Equivalent to:
//
// type jAcctImmutReq = {
// readonly ID: string;
// readonly Name: string;
// readonly Bal: number;
// };
//
type jAcctImmutReq = tImmutReq<jAcct>;
as expr
can be appended to union to set the name of the new property to a template literal type, or any other type expression that produces a literal type name. Note that el stores the original union member, and is not affected by this change. The expression el&string
may be used to filter non-string keys from the union:
type tCks<x> = {
readonly [zKey in keyof x as `Ck${zKey&string}`]-?: boolean;
}
// Equivalent to:
//
// type jCksAcct = {
// readonly CkID: boolean;
// readonly CkName: boolean;
// readonly CkBal: boolean;
// };
//
type jCksAcct = tCks<jAcct>;
In this case, the template literal appears to be generating string output, contrary to its usual function of constraining string assignment. However, the template is declaring a new literal type that constrains a property name within the mapped type, so it is still acting to constrain assignment.
Using as expr
to generate the name allows union to store elements that are not literal type keys, so long as these are transformed by expr to valid string, number, or symbol keys.
Sources
Key Remapping in Mapped TypesConditional types
A conditional type is a ternary type expression that uses extends
to compare one type with another, then selects an output type according to the result:
type extends typeBase ? typeTrue : typeFalse
As usual for TypeScript, extends
implies assignability, from left to right, rather than a class inheritance relationship:
type tModeSend<xVal> = xVal extends object
? "SYNC"|"ASYNC"
: "SYNC";
function Send<xVal>(aVal: xVal, aMode: tModeSend<xVal>): void {
...
}
Send({ID: "001"}, "SYNC");
Send({ID: "002"}, "ASYNC");
Send(0xF0, "SYNC");
// Build error: "'"ASYNC"' is not assignable...":
Send(0xF1, "ASYNC");
type can be referenced within typeTrue or typeFalse:
interface jLine {
Num: number;
A: string;
B: string;
}
type tStringOpt<x> = x extends string ? x|undefined : x;
// Equivalent to:
//
// type jLineOpt = {
// Num: number;
// A: string|undefined;
// B: string|undefined;
// }
//
type jLineOpt = {
[zKey in keyof jLine]: tStringOpt<jLine[zKey]>;
};
When type is a union, each element is evaluated separately, and the union of those results is returned. never
is ignored when added to a union, so this can used to filter types from a union:
type tRem<xEl, x> = xEl extends x ? never : xEl;
type tOpts = 0|"SOME"|"ALL"|null;
// `0|"SOME"|"ALL"`:
type tOptsNonNull = tRem<tOpts, null>;
typeBase can be set with an expression that contains infer typeInfer
. If type is found to be assignable to typeBase, the subsidiary type where infer typeInfer
was placed will be inferred and aliased as typeInfer. This allows types to be extracted from array, function, or other complex types. Because no inference is performed when type is not assignable to typeBase, such conditionals often return never
in that branch:
type tParam<x> = x extends ((a: infer zParam) => string)
? zParam
: never;
type tLook = (aID: number) => string;
// `number`:
type tParamLook = tParam<tLook>;
A conditional type of this kind can reference itself to recursively extract a nested type:
type tBaseArr<xArr> = xArr extends (infer zBase)[]
? tBaseArr<zBase>
: xArr;
type tMatr = number[][];
// `number`:
type tBaseMatr = tBaseArr<tMatr>;
Type narrowing
A type can be viewed as the set of all possible instances that share the characteristics of the type. If all the instances represented by some type (such as a union, or an ancestor class) support a given operation, that operation can be performed without checking the actual type:
let oLen: number|bigint = Read_Len();
const oText = oLen.toString();
If one or more types do not support the operation, the actual type must be established beforehand. This is called type narrowing. The check for a given type is called a type guard:
let oTextRd: string;
if (typeof oLen === "number")
oTextRd = oLen.toPrecision(3);
else oTextRd = "OVER";
Note that a simple truthiness check cannot be used as a type guard when a property’s existence is made conditional by a union. The check itself would produce a type violation:
type tOptX = {X: number};
type tOptY = {Y: number};
function Run(aOpts: tOptX|tOptY) {
// Build error: "Property 'X' does not exist...":
if (aOpts.X) ...
};
Some developers use discriminated unions to facilitate type narrowing. The object type members of these unions declare a common property called a discriminant, each with a distinct literal type. TypeScript recognizes type guards that check these properties:
type tOptX = {X: number, Type: "X"};
type tOptY = {Y: number, Type: "Y"};
function Run(aOpts: tOptX|tOptY) {
if (aOpts.Type === "X") ...
Like the evolving any, narrowing happens implicitly after a recent assignment:
let oLen: number|bigint = 0xFFFFFFFF;
let oTextRd = oLen.toPrecision(3);
Type predicates
Though TypeScript recognizes typeof
and instanceof
as type-narrowing operators, other functions may not be understood as such. If they return boolean results, these functions can be declared with type predicates that clarify their intent.
The predicate parameter is type
replaces the function’s return type. It gives the name of the parameter to be type-narrowed, plus the type to be associated with it if the function returns true
. type must be assignable to the parameter type:
function CkMode(aMode: "RUN"|"HOLD"|null): aMode is "RUN"|"HOLD" {
return aMode !== null;
}
function Exec(aMode: "RUN"|"HOLD"|null): void {
let oMode: string;
if (CkMode(aMode)) {
oMode = aMode;
...
Type assertions
Somewhat like a typecast in C#, or a static_cast
in C++, an element can be asserted as a different type by adding as
, followed by a type expression.
If the element was typed as any
or unknown
, it can be asserted to any new type; otherwise, its value must be assignable to the new type, or vice versa:
type tRank = "A"|"B"|"C";
const oText: string = "D";
const oRank: tRank = oText as tRank;
This prevents primitives from being asserted to other primitive types, but it allows class instances to be downcasted, or even asserted to or from structurally similar object types with no inheritance relationship:
interface jNode {
IDNode: string;
}
class tNodeRoot {
IDNode: string;
IDRoot: string;
...
};
function NodeNext(): jNode {
...
}
const oNodeRoot: tNodeRoot = NodeNext() as tNodeRoot;
The new type can also be specified by placing it in angle braces, just before the element. This syntax cannot be used inside tsx
files, however:
const oNodeRoot = <tNodeRoot>NodeNext();
Because any type can be asserted to or from any
and unknown
, asserting twice can produce any new type:
const oText = NodeNext() as any as string;
const assertions
Literal instances can be asserted as const
to produce literal types. When this is done with an object literal, all of its properties (plus the properties of any contained objects) become literal types:
let oArgs = {
Num: 10,
Ct: "STOP",
Ex: {Line: 0}
} as const;
A reference that is typed this way cannot be assigned with any object that does not have the exact same values:
// Build error: "Type '1' is not assignable to type '0'":
oArgs = {
Num: 10,
Ct: "STOP",
Ex: {Line: 1}
};
All properties also become readonly
, because any change to the instance would prevent it from matching its own literal type.
When an array is asserted as const
, it becomes a tuple of literal types:
// Type `(string|number)[]`:
const oEls = [1, "A"];
// Type `readonly [1, "A"]`:
const oVals = [1, "A"] as const;
Object types
As in JavaScript, an object literal specifies the content of an object in code. It associates keys with specific values, while also creating a new instance. By contrast, an object type is essentially an anonymous interface. Its declaration creates a type rather than an instance, and it associates object keys with other types. This may also be known as an object literal type, or even an object type literal. These confusing synonyms should not be mistaken for object types that contain only literal types, as when an object literal is defined with as const
.
An object type can be assigned to a type alias:
type tData = {ID: string, Ct: number};
or used anywhere else a type is expected, including inside another object type:
type tDataEx = {
ID: string;
Ct: number;
Ex: {
A: string;
B: string;
}
};
As with interfaces, excess properties are allowed when an existing instance is assigned to an object type, but not when an object literal is assigned. Excess properties cannot be referenced through the type, nor can new properties be added through the type. Object types offer all the other features of interfaces (including optional properties, read-only properties, and callable signatures) except merging and extending.
Interfaces
Interfaces are named object types. Where C# and Java classes must explicitly declare the interfaces they implement, TypeScript uses structural typing to determine the compatibility of interfaces and other object types. This resembles duck typing, but type checks are performed at build time, rather than run time. Structural typing allows class instances to be referenced through compatible interfaces without adding dependencies to class declarations, though implements
can be added to enforce interface compatibility.
Interfaces are declared with the interface
keyword, followed by a name. The body resembles other object types. Member declarations can end with commas:
interface jData {
ID: string,
Ct: number
}
or semicolons:
interface jData {
ID: string;
Ct: number;
}
Interface properties
Generally, an object can be assigned to an interface if it contains all the members required by that interface. In some cases, extra properties are ignored; TypeScript calls these excess properties. They are allowed when an existing instance is assigned to an interface reference:
const oResp = {ID: "A1", Ct: 0, Ex: null};
const oData: jData = oResp;
but a build error results if an object literal with excess properties is assigned to the reference. This error can be suppressed by prefacing the line with a comment that begins with @ts-ignore
:
// @ts-ignore
const oDataResp: jData = {ID: "A1", Ct: 0, Ex: null};
In either event, excess properties in the referenced object cannot be accessed through the interface:
// Build error: "Property 'Ex' does not exist...":
if (oData.Ex) ...
Nor can they be added through the interface:
// Build error: "Property 'Time' does not exist...":
oData.Time = new Date();
Interfaces can declare function properties:
interface jPack {
ID: string;
Name: (aSuff: string) => string;
}
or methods:
interface jPack {
ID: string;
Name(aSuff: string): string;
}
The two are equivalent, and either can be satisfied with function properties or methods inside object or class instances:
const oPack: jPack = {
ID: "X0",
Name: (aSuff: string) => ...
};
Interfaces can be used to annotate object destructuring patterns. This forces the pattern to match the interface, while also forcing callers to pass compatible objects:
function Store({ID, Ct}: jData) {
...
However, because the element: type
syntax resembles the key: variable
syntax used in some destructuring patterns, it is not possible to annotate types inside destructuring patterns.
Optional interface properties
Properties can be made optional by appending question marks to their names:
interface jData {
ID: string;
Ct?: number;
}
This allows them to be explicitly undefined
(but not null
) in types that are assigned to the interface, or omitted altogether:
const oData: jData = {ID: "A"};
By contrast, adding undefined
to a member’s type does not allow it to be omitted:
interface jData {
ID: string;
Ct: number|undefined;
}
// Build error: "Property 'Ct' is missing...":
const oData: jData = {ID: "A"};
Read-only interface properties
Interface properties can be marked readonly
to prevent them from being modified when referenced through the interface. The referenced object is allowed to be mutable, so it could be changed through another reference:
interface jEl {
readonly Ref: string;
}
let oTop = {Ref: "T+0"};
oTop.Ref = "T+1";
let oEl: jEl = oTop;
// Build error: "Cannot assign to 'Ref'...":
oEl.Ref = "T+2";
Callable interfaces
While interfaces can declare methods as members, it can also reference functions, indexers, and constructors directly, without any containing object. This is consistent with JavaScript’s treatment of functions as first-class objects.
Call signatures
A call signature allows the interface as a whole to represent a function. Syntactically, it resembles an anonymous method declaration. It allows references typed with the interface to be invoked with the specified signature:
interface jPred {
(aIdx: number): boolean;
}
function PredFirst(aIdx: number): boolean {
return (aIdx === 0);
}
let oPred: jPred = PredFirst;
oPred(0);
Note that callable interfaces can also include property members. When properties accompany a call signature, the interface describes a function to which properties have been assigned:
function Run(aCt: number): void {
...
}
Run.Mode = "FAST";
interface jRun {
(aCt: number): void;
Mode: string;
}
const oRun: jRun = Run;
Index signatures
An index signature represents an operation that dereferences an instance with bracket notation. It resembles a call signature, but it replaces the parentheses with brackets. It accepts exactly one parameter, which must be a string
or number
:
interface jCtsByID {
[aID: string]: number;
}
const oCtsByID: jCtsByID = {"A1": 1, "A2": 11};
const oCtA1 = oCtsByID["A1"];
Note that indexers bypass the property existence checks that TypeScript normally performs, even when the dot notation is used:
// No build error; produces `undefined`:
const oCtB1 = oCtsByID.B1;
If an interface combines an index signature with one or more properties, the indexer could be used to read those properties, so the property types must be assignable to the indexer return type. Methods are also properties, so combining an index signature with methods requires that the indexer return a function type, and the method types must be assignable to this function type.
This principle also applies when objects are associated with an index signature interface, even if the interface declares no properties. When an object literal is assigned directly to the interface, each of its properties must match the index signature. Note that when a property name is incompatible, TypeScript complains that it ‘does not exist’:
interface jPool {
[aKey: number]: string;
}
const oPool: jPool = {
0: "READY",
1: "PEND",
// Build error: "'number' is not assignable...":
2: 10,
// Build error: "'DEF' does not exist...":
DEF: "DONE"
};
When the same object is assigned indirectly, some incompatible properties may be accepted, but it will not be possible to access them through the interface:
const oData = {
0: "READY",
1: "PEND",
2: 10,
DEF: "DONE"
};
// Build error: "Property '2' is incompatible...":
const oPool: jPool = oData;
// Build error: "Property 'DEF' does not exist...":
const oDef = oPool.DEF;
Construct signatures
A construct signature represents a constructor. It also resembles a call signature, but it is prefixed with new
:
class tOrd {
constructor (aID: string) {
...
}
class tOrdWhole extends tOrd {
constructor (aID: string) {
super(aID);
...
}
interface jConstructOrd {
new (aID: string): tOrd;
}
const oConstructOrd: jConstructOrd = tOrdWhole;
const oOrd = new oConstructOrd("A");
Sources
Tackling TypeScript: String keys vs. number keysMerging interfaces
It is possible to declare multiple interfaces with the same name. When this is done, the declarations automatically combine to produce a single interface with all the constituents’ members:
interface jPack { ID: string; }
...
interface jPack { IDNext: string; }
const oPack: jPack = { ID: "000", IDNext: "001" };
Constituent declarations can declare members with the same names. When non-method member names are duplicated, the members must be identical in type. When method names are duplicated, their signatures are allowed to vary. This overloads the methods, the same way it would if they were declared together.
Extending interfaces
One interface can extend another, causing it to inherit all the members of that interface. The extending interface lists the parent’s name after its own, with extends
between:
interface jStats {
Time: Date;
}
interface jStatsPart extends jStats {
CdPart: string;
}
One interface can extend multiple parents, causing it to gain all of their members:
interface jSvc {
Path: string;
}
interface jStatsSvc extends jStats, jSvc {}
const oStatsSvc: jStatsSvc = {
Time: new Date(),
Path: "/mon/net"
};
An extending interface can override a member in an ancestor by redeclaring it with the same name and a different type. The new type must be assignable to the ancestor type:
interface jTask {
IDTask: number;
Opts: string|null;
}
interface jRun extends jTask {
Opts: string;
}
const oRun: jRun = {
IDTask: 100,
Opts: "XA"
};
Classes
JavaScript classes use prototypal inheritance, which connects superclasses, subclasses, constructors, and class instances in very specific ways. These connections underlie all classes, whether they are specified with ES6 class declarations, or set up manually.
TypeScript defines a class type for each ES6 class declaration. However, this type is defined independently of JavaScript’s prototypal class system, and it can easily be made to contradict that system. For example, any object can be assigned to a class type reference, so long as it is structurally similar to that class:
class tAct {
readonly Cd: string = "A"
Exec() { ... }
}
const oObj = {Cd: "Z", Exec: () => { ... }};
const oAct: tAct = oObj;
This means that instances referenced by class types cannot be assumed to be actual members of that class:
// `false`:
let oCk = oAct instanceof tAct;
Nor can it be assumed that they were constructed by the class, nor even that their methods are the methods defined in the class declaration. Though the JavaScript class behaves as it always would, the class type acts more like an interface.
Class properties
As with object types, properties cannot be added to instances that are referenced through a class; only fields defined in the class can be assigned or accessed. Like interfaces, TypeScript does allow an existing instance with excess properties to be assigned to a class reference, as long as all class members are represented within object.
Class property initialization
When --strictPropertyInitialization
is enabled, TypeScript requires that every class field be initialized, in the body or the constructor, unless it is optional, or unless it includes undefined
in its type. This can be disabled for a specific field by appending the definite assignment assertion operator !
to its name:
class tMgrBatch {
IDLast: string;
IDNext!: string;
constructor () {
this.IDLast = "001";
...
This allows the field to be left undefined
when the class is constructed without allowing it to be assigned with undefined
later. It prevents TypeScript from requiring undefined
checks when the field is accessed, however, as it would do if undefined
were part of the type, and if --strictNullChecks
were enabled.
Optional class properties
Like interface properties, class properties can be made optional by appending ?
to their names. This bypasses the --strictPropertyInitialization
requirement, and when --exactOptionalPropertyTypes
is set, it prevents undefined
from being assigned to these properties unless that type is part of the property’s type.
Read-only class properties
Like interfaces, classes can declare properties readonly
. However, when readonly
class property types are inferred from primitive-type initializers, they are assumed to be literal types. Also, readonly
confers no protection in the generated js
output, so second-party JavaScript code that uses the class can change its readonly
properties.
Parameter properties
Many classes simply forward constructor parameters to properties for storage. Prefixing a constructor parameter with one of the access modifiers, or with readonly
, or with an access modifier followed by readonly
causes TypeScript to declare and initialize a property with the same name:
class tCkBal {
constructor(readonly Min: number, private Curr: number) {}
Ck(): boolean { return this.Curr >= this.Min; }
}
This cannot be used to define one of JavaScript’s private properties.
Access modifiers
ES2022 allows the definition of private properties, which produce run-time errors when referenced outside the class. TypeScript offers its own access modifiers that predate ES2022:
-
Members are
public
by default, but they can also be explicitly declared this way. These members can be accessed from anywhere; -
protected
members can be accessed only by the class and its descendants; -
private
members can be accessed only by the class.
class tServ {
Name: string;
protected CdSt: string;
private Mode: string;
#Key: string;
...
Like readonly
, these modifiers offer no protection in the generated js
output.
Extending classes
Abstract classes
TypeScript allows the class
keyword to be prefixed with abstract
; when this is done, a build error results if any attempt is made to instantiate the class. This also allows methods and properties in the class to be declared abstract
by prefixing their names. These members must be overridden in the first descendant class that is not also abstract
. Moreover, abstract
methods cannot be implemented in the abstract
class, and abstract
properties cannot be initialized:
abstract class tAct {
abstract Name: string;
abstract Exec(): void;
}
class tActUndo extends tAct {
override Name: string = "Undo";
override Exec(): void { ... }
}
Overriding class members
As in JavaScript, subclasses can override properties and methods by redefining them with different values or implementations. In TypeScript, these overrides can have different types as well; however, property overrides must be assignable to the corresponding ancestor properties. This means each subclass type must match the ancestor type or be more specific:
class tRd {
Type: string|null = null;
}
class tRdAct extends tRd {
override Type: string = "ACT";
}
For correctness, when the property has a function type, this entails the usual contravariance of parameter types and covariance of return types. However, these rules are not enforced for properties that reference functions unless --strictFunctionTypes
is set, and they are never enforced for method or constructor parameters. Instead, these parameters are expected to be bivariant: the types must be related, but they can contravary or covary.
Because method parameters are bivariant, methods can be overridden in ways that are not type-safe. Also, inheritance always produces overrides, and never overloads. Though a signature in the parent might fit the method invocation more closely, the child’s override is always used, even when the method is invoked through a parent reference:
class tRep {
Cd(aVal: string|number, aEx: string): string|null {
return `Rep:${typeof(aVal)}-${aVal}`;
}
}
class tRepPast extends tRep {
override Cd(aVal: number): string {
return `RepPast:${typeof(aVal)}-${aVal}`;
}
}
const oRep: tRep = new tRepPast();
// "RepPast:string-AA", though `aVal` is a `number`:
let oCd = oRep.Cd("AA", "EX");
// "RepPast:number-0", though one argument is expected:
oCd = oRep.Cd(0, "EX");
Sources
Strict function types TypeScript 2.6: Strict function types Why are function parameters bivariant? Difference between Variance, Covariance, Contravariance...override
In TypeScript, properties and methods can be prefixed with override
to show they are meant to override members in an ancestor. If a prefixed member does not match an inherited member, a build error will result. If --noImplicitOverride
is set, a build error will also result if a subclass duplicates a property or method in some ancestor without declaring itself to be an override
.
Implementing interfaces
Though a class instance can be assigned to any compatible interface reference, a class can also enforce its compatibility by declaring implements
after its name, followed by one or more comma-separated interface names. This produces a build error if the class fails to define any member of the specified interfaces:
interface jMgr {
IDMgr: string;
}
class tMgrBatch implements jMgr {
IDMgr = "BATCH";
...
Generics
TypeScript allows interfaces, classes, functions, and type aliases to be defined as generics. It associates arrays with its own generic Array
interface, and it uses its generic Promise
interface to reference promises.
The generic syntax resembles C#, with type parameters being declared in angle brackets just after the name. In arrow functions, they appear just before the function parameter list, where they would appear in a named function:
let CdRef = <xIdx>(aIdx: xIdx) => "REF" + aIdx;
const oCd = CdRef(100);
Inside a tsx
file, this produces a “no corresponding closing tag” error. That can be avoided by declaring unknown
(or any other type) as the default for one or more parameters:
let CdRef = <xIdx = unknown>(aIdx: xIdx) => "REF" + aIdx;
Type parameters can be used in type expressions throughout the generic:
class tPt<xNum> {
readonly X: xNum;
readonly Y: xNum;
constructor(aX: xNum, aY: xNum) {
this.X = aX;
this.Y = aY;
}
}
Generic classes can be subclassed. Type parameters can be forwarded from the subclass to the parent, producing another generic, or the parent class can be specialized in the extends
declaration, allowing a non-generic subclass:
class tCoord extends tPt<number> {
...
Classes can implements
generic interfaces in like manner.
When a generic is specialized, type arguments replace the parameters:
const oPtOrig = new tPt<BigInt>(0n, 0n);
Omitting the arguments and brackets allows the types to be inferred. If TypeScript cannot infer a type, it will assign unknown
, likely producing build errors. If any type argument without a default is omitted from the specialization, all must be omitted:
const oPtOrig = new tPt(0n, 0n);
Default type parameters
Default types can be assigned to trailing parameters. Each default can be a type or a type parameter that was declared earlier in the parameter list. A given default is used if the corresponding argument is not provided, and if no type can be inferred:
class tStore<x = string> {
#Els: x[] = [];
Add(a: x) {
...
}
}
const oStore = new tStore();
oStore.Add("A");
Generic type constraints
A type parameter can be constrained by adding extends
, followed by another type. This requires that the parameter be specialized with a type that is assignable to the second type. Though the second type could be an ancestor of the first, this extends
does not require an inheritance relationship. The second type is often a union or an interface:
function Mid<x extends {Start:number, End:number}>(a: x): number {
return (a.Start + a.End) / 2;
}
const oMid = Mid({Start: 10, End: 20, Stat: "READY"});
Generic variance annotations
Structural typing allows instances to be referenced through interfaces or other types even when the types contained by these do not match, so long as the contained types are assignable where necessary:
class tEl {
ID: number = ...
}
class tElEx extends tEl {
Ex: string = ...
}
const oElsEx: tElEx[] = [new tElEx()];
const oEls: tEl[] = oElsEx;
As usual, this means function parameter types should contravary, staying the same or becoming more general in the instance, while return types should covary, staying the same or becoming less general. TypeScript enforces these rules in some cases, but not others.
Even when it does enforce them, in “extremely rare cases involving certain kinds of circular types”, TypeScript may choose the wrong variance relationship between types. This can be fixed by adding variance annotations just before the affected type parameters. The out
annotation shows that the type is meant to contravary:
function ReadElEx(aID: number): tElEx {
...
}
type tRead<out x> = (aID: number) => x;
const oReadEl: tRead<tEl> = ReadElEx;
The in
annotation shows that it should covary:
function WriteEl(aID: number, aEl: tEl): void {
...
}
type tWrite<in x> = (aID: number, aEl: x) => void;
const oWriteElEx: tWrite<tElEx> = WriteEl;
The in out
annotation shows that it should be invariant, meaning that it should not change:
function CloneEl(aEl: tEl): tEl {
...
}
type tClone<in out x> = (aEl: x) => x;
const oCloneEl: tClone<tEl> = CloneEl;: tEl {
Sources
Covariant, Contravariant, and Invariant in Typescript GenericsFunctions
Function types describe functions, and are used to type function references, much the way object types describe non-function objects. Their structure differs significantly from function implementation annotations, however.
When a function is implemented, its return type is annotated just after the parameter list. This holds for function declarations and expressions:
function Read(aIdx: number): string|null {
...
and also for arrow functions:
const Read_First = (): string|null => Read(0);
If no return type is declared, and if that type cannot be inferred, the function is assumed to return void
.
Function types are declared with something like the arrow function syntax, but their return types replace the implementation rather than being attached to the parameter list:
let oOp: (aIdx: number) => string|null;
The union operator has higher precedence than the arrow operator, so if the function type is meant to be part of a union (rather than something that returns a union), it must be parenthesized:
let oOp: ((aIdx: number) => string)|null;
Note that parameter names must be declared in function types; without them, the parameter types will be interpreted as names, and TypeScript will complain that the types are missing. These names are ignored when matching function implementations to function types.
Rest parameters can be annotated as arrays or tuples:
let oFlag: (...aStats: [string, number]) => number;
Function assignability
A function instance is assignable to a function type if the type has as many or more parameters, and if the return types covary, so that the instance returns the same type or one that is more specific. If --strictFunctionTypes
is set, the parameter types must contravary as well, so the instance must accept the same types or ones that are more general. Note that --strictFunctionTypes
does not apply to method parameters, which are never checked for contravariance.
Sources
Why are functions with fewer parameters assignable...Optional and default parameters
TypeScript produces a build error if any function is invoked with extra parameters, or if any non-optional, non-default parameter is omitted. Parameters at the end of the parameter list can be made optional by appending question marks to their names. If --strictNullChecks
is set, TypeScript also changes the types of these parameters to unions that include undefined
. This makes it necessary to check for undefined
before they are used:
function Upd(aCt: number, aCd?: string) {
if (aCd !== undefined) {
const oLenCd = aCd.length;
...
Note that adding undefined
to a parameter’s type does not by itself allow that parameter to be omitted, even though omitting it leaves it undefined
in JavaScript:
let oReg: (aName: string|undefined) => void;
...
// Build error: "An argument for 'aName' was not provided":
oReg();
Within the implementation, undefined
could be added as a parameter default; however, defaults cannot be defined inside function types.
As in JavaScript, passing undefined
through a parameter with a default value causes the default to be used inside the function.
void and never return types
A function’s return can be declared void
to show that its return value is undefined
. Such a function can return
without an argument, it can return undefined
explicitly, or it can omit return
altogether. This value is not assignable to undefined
, however; it is assignable only to another void
.
Though a void
-returning function cannot return anything but undefined
, a function type with a void
return can be assigned with functions that return any type, with the understanding that the actual return values will be ignored:
function Summ(aMode: string): string {
...
}
type tExec = (aMode: string) => void;
let oExec: tExec = Summ;
A function that returns no value implicitly returns undefined
. Neither undefined
nor any other value fits the never
type, so a function that returns never
is not allowed to return, not even by reaching the end of its implementation. Such a function must run forever or throw.
Function overloads
Languages that support function overloading typically associate each overload with a unique implementation. TypeScript supports overloading, but it allows only one implementation, and that function is not directly callable.
The function’s callable forms are specified by two or more overload signatures, which are merely declarations:
function Look_Batch(aIDBatch: number): string;
function Look_Batch(aCdRgn: string, aCdLot: string): string;
These are followed by the implementation, which has an implementation signature that must be a superset of all the overload signatures. It must accept at least as many parameters as the longest overload signature, and every parameter in an overload must be assignable to the implementation parameter at the same position. Implementation parameters at the start of the list will likely accept multiple types, while the last few may accept only one. Unless every overload has the same parameter count, one or more trailing parameters must be optional within the implementation:
function Look_Batch(
aIDBatchOrCdRgn: number|string,
aCdLot?: string
): string {
if (typeof aIDBatchOrCdRgn === "number") ...
}
The implementation function must check the existence and type of its arguments before using them, as is traditional for JavaScript functions that accept varying inputs. TypeScript compares function invocations against the overload types at build time, then removes the overloads when generating js
files, leaving the calls to be handled by the implementation:
let oBatch = Look_Batch(100);
...
oBatch = Look_Batch("CENT", "A00");
The same pattern is used to overload methods. An interface can declare the overload signatures:
interface jMgrBatch {
Look_Batch(aIDBatch: number): string;
Look_Batch(aCdRgn: string, aCdLot: string): string;
}
and be implemented by an object that provides the implementation:
const MgrBatch: jMgrBatch = {
Look_Batch(aIDBatchOrCdRgn: number|string, aCdLot?: string): string {
...
}
};
Alternatively, the overload signatures and implementation can be defined together, inside a class or object.
It is also possible to overload call signatures in an interface:
interface jLook_Batch {
(aIDBatch: number): string;
(aCdRgn: string, aCdLot: string): string;
}
and then use the interface to reference the implementing function:
const oLook: jLook_Batch = Look_Batch;
oBatch = oLook(100);
...
oBatch = oLook("CENT", "A00");
Overloads are often distinguished by parameter counts and non-literal types, but they can also be distinguished by literal types:
function Ready(aID: string, aRt: "MAIN"): void;
function Ready(aID: string, aRt: "ALT"): void;
function Ready(aID: string, aRt: string): void {
...
}
Enumerations
TypeScript adds support for enumerations, with a number of strange behaviors not found in other languages.
The basic syntax resembles C#. Unless they are assigned specific values, the first member is implicitly zero, and the next members follow in sequence. Value expressions can reference already-defined members, and the same value can be shared by multiple members.
Values can also be calculated at run time, producing computed members:
enum tStat {
Wait,
Ready = 10,
Active = Ready + 10,
Running = Active,
Stopped = CdStop()
};
function CdStop(): number {
...
}
Members can also be assigned with string values, but these cannot be computed:
enum tMode {
Unset = 0,
Def = "DEFAULT",
Cust = "CUSTOM"
}
The enumeration name references a new type that generally matches both the keys:
function Upd(aStat: tStat) {
if (aStat === tStat.Running) {
...
and the values within the enumeration:
function Exec(aMode: tMode) {
if (aMode === "DEFAULT") {
...
However, though numeric enumeration values are assignable to enumeration types:
let oMode: tMode = 0;
string values cannot be assigned directly:
// Build error: "Type '"CUSTOM"' is not assignable":
oMode = "CUSTOM";
They can be assigned only as keys:
oMode = tMode.Cust;
Enumeration members are themselves usable as types:
function Ready(aMode: tMode.Def|tMode.Cust): void {
...
Enumeration content is stored in an object, also referenced by the enumeration name. Generally, this object maps in two directions: from key names to values, and from values to keys:
// 0:
const oValUnset: tMode = tMode["Unset"];
// "DEFAULT":
const oValDef: tMode = tMode["Def"];
// "Unset":
const oKeyUnset: string = tMode[0];
However, string values are not mapped back to keys:
// Build error: "Element implicitly has an 'any' type
// because index expression is not of type 'number'":
let oKeyDef: string = tMode["DEFAULT"];
When applied to an enumeration, keyof typeof
returns a union of the original key names, ignoring the value-to-key properties.
Like interfaces, multiple enumerations can be declared with the same name. When this is done, all members in the new declaration are added to the existing enumeration:
enum tMode {
Spec = 1
}
let oModeErr: tMode.Unset|tMode.Spec;
Constant enumerations
Members that aren’t computed are called constant members. An enumeration that contains only constant members can be prefixed with const
to produce a constant enumeration:
const enum tRank { A, B, C }
This produces no enumeration object. Instead, all enumeration references are replaced in the js
file output by their number or string values, with comments identifying the members that produced these.
This avoids the property lookups that normally happen when enumeration objects are used. However, including constant enumerations in d.ts
files can produce dangerous bugs and difficult build problems in dependent projects.
Declaration merging
TypeScript often allows the same name to be used in multiple declarations within the same scope. Some developers (and the official documention) confoundingly refer to this as declaration merging, but it is actually three separate and largely unrelated phenomena.
First, TypeScript types are build-time entities that are syntactically disjoint from instances, which exist only at run time. Type declarations that do not also produce instances (such as type aliases and interfaces) can therefore share names with instances. Conversely, namespaces (which produce instances but not like-named types) can share names with no-instance types. There is no merging; the spaces these entities inhabit simply do not overlap:
let Batch: number = 100;
interface Batch { ID: number };
// `{ID: 100}`:
const Data: Batch = {ID: Batch};
Second, class declarations and enumerations do produce types plus function or object instances with the same names, so they cannot share names with most instances. Namespaces also produce like-named object instances, so the same limitation applies. However, enumerations:
enum Dest { Near, Far }
produce js
file output that instantiates a new object, or extends an existing instance, if one is found:
var Dest;
(function (Dest) {
Dest[Dest["Near"] = 0] = "Near";
Dest[Dest["Far"] = 1] = "Far";
})(Dest || (Dest = {}));
Namespaces work similarly:
namespace Lim {
const CtMax = 100;
export const IdxMax = CtMax - 1;
}
though they use the containing IIFE to encapsulate elements that were not exported:
var Lim;
(function (Lim) {
const CtMax = 100;
Lim.IdxMax = CtMax - 1;
})(Lim || (Lim = {}));
In either event, TypeScript uses this pattern to combine multiple declarations into a single enumeration object instance, or a single namespace object instance. It also uses it to add namespace exports to enumeration instances, class constructor instances, or function instances, so long as these instances do not already contain members that conflict with the exported names:
function Run(aLvl = Run.LvlDef) {
...
}
namespace Run {
export const LvlDef = 0;
}
If any merging occurs, it happens at run time, and is simply a matter of adding new properties to an existing object. Semantics aside, the underlying implementation is easier to understand than the leaky abstraction that merging attempts to provide. Note that the same pattern could be used to add enumeration members to classes and functions, but TypeScript does not allow that.
Third, interface declarations that share the same name are actually merged, in the sense that they are indistinguishable from a single, combined declaration. A kind of merging also occurs when an interface and a class share the same name. But, while the class effectively extends the interface, the interface does not extend the behavior of the class:
class Job {
Prior: number = 0;
}
interface Job {
// No "has no initializer and is not definitely assigned
// in the constructor" build error here, even though
// `--strictPropertyInitialization` is enabled:
ID: number;
}
let oJob: Job = {ID: 100, Prior: 1};
// Build error: "Property 'ID' is missing...":
oJob = {Prior: 2};
// Build error: "Property 'Prior' is missing...":
oJob = {ID: 100};
oJob = new Job();
// `undefined`:
const oID = oJob.ID;
This is another way that TypeScript class types (as distinct from the JavaScript classes they represent) behave less like classes in other languages, and more like interfaces.
General sources
Learning TypeScript
Josh Goldberg
2022, O’Reilly Media, Inc.
The TypeScript Handbook
Microsoft
TypeScript FAQ
Microsoft